From 8ae0353c0047629f82e5b95b1e2968791c2bf8fd Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Wed, 23 Sep 2026 17:00:26 +0200
Subject: [PATCH 1/4] feat(client): Give the mobile drawer one place for each
control
The drawer put its two global destinations in opposite corners and repeated
one of them under every panel. The marketplace was a bare icon in the top
strip, the hardest thing there to notice; new chat was a large footer button
sitting under prompts, memories and MCP settings alike, none of which it has
anything to do with; and the close toggle wore the chat header's bordered
plate beside two flat icons, so the row read as one control and two
afterthoughts.
They swap. New chat takes the icon slot beside the panel switcher, which is
where the drawer keeps what means the same thing whichever panel is showing,
and its click behaviour moves with it into a component of its own rather than
staying behind in the footer. The marketplace becomes a full-width entry
above the panel content, labelled, in the list where someone already looks
for somewhere to go, and outside the scroller so it stays put while the
panels change under it. The toggle drops to `ghost` like its neighbours. The
footer is left with search alone and stands down entirely on a panel that has
nothing to search, rather than taking its padding out of the list above it.
The drawer now carries a text colour beside its background. Only the panel
nav set one, so the header strip, the new marketplace row and the footer all
inherited black from the document and rendered near-invisible in dark mode,
except where a control happened to name a token itself. That was hidden while
the toggle wore `header-action`, which brought its own.
---
.../components/Nav/AgentMarketplaceButton.tsx | 79 +++++++-----
.../__tests__/AgentMarketplaceButton.spec.tsx | 67 ++++++++++
.../UnifiedSidebar/UnifiedSidebar.tsx | 13 +-
.../__tests__/drawerPaint.spec.tsx | 1 +
.../UnifiedSidebar/mobile/BottomBar.tsx | 87 +++----------
.../UnifiedSidebar/mobile/Header.tsx | 13 +-
.../UnifiedSidebar/mobile/NewChat.tsx | 74 ++++++++++++
.../mobile/__tests__/BottomBar.spec.tsx | 114 ++++++------------
.../mobile/__tests__/Header.spec.tsx | 75 ++++++------
.../mobile/__tests__/NewChat.spec.tsx | 103 ++++++++++++++++
eslint-suppressions.json | 8 +-
11 files changed, 412 insertions(+), 222 deletions(-)
create mode 100644 client/src/components/Nav/__tests__/AgentMarketplaceButton.spec.tsx
create mode 100644 client/src/components/UnifiedSidebar/mobile/NewChat.tsx
create mode 100644 client/src/components/UnifiedSidebar/mobile/__tests__/NewChat.spec.tsx
diff --git a/client/src/components/Nav/AgentMarketplaceButton.tsx b/client/src/components/Nav/AgentMarketplaceButton.tsx
index a9416d7cba2..df33d69d408 100644
--- a/client/src/components/Nav/AgentMarketplaceButton.tsx
+++ b/client/src/components/Nav/AgentMarketplaceButton.tsx
@@ -2,13 +2,22 @@ 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
@@ -16,6 +25,7 @@ interface AgentMarketplaceButtonProps {
export default function AgentMarketplaceButton({
side = 'right',
onNavigate,
+ layout = 'icon',
}: AgentMarketplaceButtonProps) {
const localize = useLocalize();
const showAgentMarketplace = useShowMarketplace();
@@ -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 = (
+
+ );
+
+ if (isRow) {
+ return button;
+ }
+
return (
-
- {
- if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
- return;
- }
- onNavigate?.();
- }}
- >
-
-
-
- }
- />
+
);
}
diff --git a/client/src/components/Nav/__tests__/AgentMarketplaceButton.spec.tsx b/client/src/components/Nav/__tests__/AgentMarketplaceButton.spec.tsx
new file mode 100644
index 00000000000..c1ea752bc70
--- /dev/null
+++ b/client/src/components/Nav/__tests__/AgentMarketplaceButton.spec.tsx
@@ -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 }) => (
+
+ ),
+ TooltipAnchor: ({ render: trigger }: { render: React.ReactNode }) => trigger,
+}));
+
+import AgentMarketplaceButton from '../AgentMarketplaceButton';
+
+const renderButton = (layout?: 'icon' | 'row') =>
+ render(, { 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();
+ }
+ });
+});
diff --git a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx
index 2adbef4e647..70f97d925dc 100644
--- a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx
+++ b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx
@@ -20,6 +20,7 @@ import { filesDialogTriggerAtom, showFilesDialogAtom } from '~/store/filesDialog
import { ChatContext, ChatFormProvider, ActivePanelProvider } from '~/Providers';
import { MobileHeader, MobileBottomBar, MobileShortcutTargets } from './mobile';
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
+import AgentMarketplaceButton from '~/components/Nav/AgentMarketplaceButton';
import useUnifiedSidebarLinks from '~/hooks/Nav/useUnifiedSidebarLinks';
import useSidebarToggle from '~/hooks/Nav/useSidebarToggle';
import useSidebarState from '~/hooks/Nav/useSidebarState';
@@ -196,7 +197,7 @@ function UnifiedSidebar({ isSliding = false }: { isSliding?: boolean }) {
/** The close swipe reads horizontal touches here (the drawer holds no
* 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',
expanded ? 'translate-x-0' : '-translate-x-full',
)}
style={{
@@ -221,9 +222,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. */}
+
+
+
-
+
diff --git a/client/src/components/UnifiedSidebar/__tests__/drawerPaint.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/drawerPaint.spec.tsx
index 4fd08f72cfc..e939e2d4524 100644
--- a/client/src/components/UnifiedSidebar/__tests__/drawerPaint.spec.tsx
+++ b/client/src/components/UnifiedSidebar/__tests__/drawerPaint.spec.tsx
@@ -42,6 +42,7 @@ jest.mock('~/hooks/Nav/useUnifiedSidebarLinks', () => ({
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useChatHelpers: () => ({}),
+ useShowMarketplace: () => false,
}));
jest.mock('~/Providers', () => ({
diff --git a/client/src/components/UnifiedSidebar/mobile/BottomBar.tsx b/client/src/components/UnifiedSidebar/mobile/BottomBar.tsx
index 4b451e3a016..2d834d3da03 100644
--- a/client/src/components/UnifiedSidebar/mobile/BottomBar.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/BottomBar.tsx
@@ -1,90 +1,37 @@
-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 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) => {
- 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;
+ if (!showSearch) {
+ return null;
+ }
+
return (
- {showSearch && (
-
-
-
- )}
-
+
+
+
);
}
diff --git a/client/src/components/UnifiedSidebar/mobile/Header.tsx b/client/src/components/UnifiedSidebar/mobile/Header.tsx
index 0a19aebbb89..966d8295417 100644
--- a/client/src/components/UnifiedSidebar/mobile/Header.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/Header.tsx
@@ -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'));
@@ -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;
}) {
@@ -57,14 +59,17 @@ function Header({
id={expanded ? CLOSE_SIDEBAR_ID : undefined}
data-testid={expanded ? 'close-sidebar-button' : undefined}
size="icon"
- variant="header-action"
+ /** `ghost`, like the controls it shares the strip with. `header-action`
+ * carries the chat header's bordered plate, which reads over a scrolling
+ * gradient and reads as an odd box beside two flat icons here. */
+ variant="ghost"
aria-label={localize('com_nav_close_sidebar')}
aria-expanded={expanded}
aria-controls="chat-history-nav"
/** The only close control while open, so its binding must be discoverable here. */
aria-keyshortcuts={toggleSidebarAriaKey}
tabIndex={expanded ? 0 : -1}
- className="shrink-0"
+ className="h-9 w-9 shrink-0"
onClick={onClose}
>
@@ -75,7 +80,7 @@ function Header({
onNavigate={onClose}
routeActiveId={routeActiveId}
/>
-
+ }>
diff --git a/client/src/components/UnifiedSidebar/mobile/NewChat.tsx b/client/src/components/UnifiedSidebar/mobile/NewChat.tsx
new file mode 100644
index 00000000000..6131d441299
--- /dev/null
+++ b/client/src/components/UnifiedSidebar/mobile/NewChat.tsx
@@ -0,0 +1,74 @@
+import { memo, useCallback } from 'react';
+import { useRecoilValue } from 'recoil';
+import { SquarePen } from 'lucide-react';
+import { Button, TooltipAnchor } from '@librechat/client';
+import { useActivePanel, DEFAULT_PANEL } from '~/Providers';
+import { useShortcutAriaKey } from '~/hooks/useKeyboardShortcuts';
+import useNewChat from '~/hooks/Chat/useNewChat';
+import { useLocalize } from '~/hooks';
+import store from '~/store';
+
+/**
+ * New chat, in the drawer's header strip beside the panel switcher.
+ *
+ * It sat in the footer, which put it under the thumb but also under every panel,
+ * repeating a destination that has nothing to do with prompts or memories. The
+ * header is where the drawer keeps the controls that mean the same thing
+ * whichever panel is showing.
+ */
+function NewChat({ onNewChat }: { onNewChat: (afterSlide?: () => void) => void }) {
+ const localize = useLocalize();
+ const switchToHistory = useRecoilValue(store.newChatSwitchToHistory);
+ const { setActive } = useActivePanel();
+ const { startNewChat } = useNewChat();
+
+ /**
+ * Close first, reset second, the inverse of `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) => {
+ 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');
+
+ return (
+
+
+
+
+
+ }
+ />
+ );
+}
+
+export default memo(NewChat);
diff --git a/client/src/components/UnifiedSidebar/mobile/__tests__/BottomBar.spec.tsx b/client/src/components/UnifiedSidebar/mobile/__tests__/BottomBar.spec.tsx
index d2fe5e5c361..fc39ac33ffc 100644
--- a/client/src/components/UnifiedSidebar/mobile/__tests__/BottomBar.spec.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/__tests__/BottomBar.spec.tsx
@@ -1,33 +1,13 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { act, cleanup, render, screen, fireEvent } from '@testing-library/react';
+import { render, screen, cleanup } from '@testing-library/react';
import type { NavLink } from '~/common';
-jest.mock('~/hooks', () => ({
- useLocalize: () => (key: string) => key,
-}));
-
-jest.mock('~/hooks/useKeyboardShortcuts', () => ({
- useShortcutAriaKey: () => 'Meta+Shift+O',
-}));
-
-jest.mock('@librechat/client', () => ({
- Button: ({
- children,
- asChild: _asChild,
- ...props
- }: {
- children: React.ReactNode;
- asChild?: boolean;
- }) =>
{children}
,
- /** The real `useNewChat` pulls the file-deletion mutation, which reads the toast context. */
- useToastContext: () => ({ showToast: jest.fn() }),
-}));
+const activePanel = { active: 'conversations' };
jest.mock('~/Providers', () => ({
- useActivePanel: () => ({ active: 'conversations', setActive: jest.fn() }),
- resolveActivePanel: () => 'conversations',
+ useActivePanel: () => ({ active: activePanel.active, setActive: jest.fn() }),
+ resolveActivePanel: (active: string) => active,
DEFAULT_PANEL: 'conversations',
}));
@@ -36,75 +16,49 @@ jest.mock('~/components/Nav/SearchBar', () => ({
default: () => ,
}));
-/** The heavy reset itself is the unit under order-test; only the navigation
- * internals below useNewChat are stubbed. */
-const mockNewConversation = jest.fn();
-jest.mock('~/hooks/useNewConvo', () => ({
- __esModule: true,
- default: () => ({ newConversation: mockNewConversation }),
-}));
-
import BottomBar from '../BottomBar';
+import store from '~/store';
const links = [] as NavLink[];
-let queryClient: QueryClient;
-
-describe('mobile bottom bar — new chat ordering', () => {
- beforeEach(() => {
- queryClient = new QueryClient();
- jest.clearAllMocks();
- });
+const renderBar = (searchEnabled = true) =>
+ render(
+
+ set(store.search, (prev) => ({ ...prev, enabled: searchEnabled }))
+ }
+ >
+
+ ,
+ );
+
+describe('mobile bottom bar', () => {
afterEach(() => {
+ activePanel.active = 'conversations';
cleanup();
- queryClient.clear();
});
- /**
- * The drawer close must start BEFORE the conversation reset: run
- * synchronously, the reset's cache clearing and navigation flush in the
- * tap's task and stall the slide's first frame. The reset rides the
- * close's `afterSlide` callback instead.
- */
- it('closes the drawer first and defers the conversation reset to afterSlide', () => {
- const order: string[] = [];
- let afterSlide: (() => void) | undefined;
- const onNewChat = jest.fn((callback?: () => void) => {
- order.push('close');
- afterSlide = callback;
- });
- mockNewConversation.mockImplementation(() => {
- order.push('reset');
- });
+ /** New chat moved to the header strip: repeated under every panel it was a
+ * second, larger copy of a destination the panel has nothing to do with. */
+ it('carries search and nothing else', () => {
+ renderBar();
- render(
-
-
-
-
- ,
- );
- fireEvent.click(screen.getByTestId('nav-new-chat-fab'));
+ expect(screen.getByTestId('search-bar')).toBeInTheDocument();
+ expect(screen.queryByTestId('nav-new-chat-fab')).not.toBeInTheDocument();
+ });
- expect(order).toEqual(['close']);
- expect(afterSlide).toBeDefined();
+ /** Searching messages only means anything from the conversation list, and an
+ * empty footer would still take its padding out of the list above it. */
+ it('stands down on a panel that has nothing to search', () => {
+ activePanel.active = 'prompts';
+ const { container } = renderBar();
- act(() => afterSlide?.());
- expect(order).toEqual(['close', 'reset']);
+ expect(container).toBeEmptyDOMElement();
});
- it('leaves modified clicks to the browser (new tab)', () => {
- const onNewChat = jest.fn();
- render(
-
-
-
-
- ,
- );
- fireEvent.click(screen.getByTestId('nav-new-chat-fab'), { ctrlKey: true });
+ it('stands down where the deployment has search off', () => {
+ const { container } = renderBar(false);
- expect(onNewChat).not.toHaveBeenCalled();
- expect(mockNewConversation).not.toHaveBeenCalled();
+ expect(container).toBeEmptyDOMElement();
});
});
diff --git a/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx b/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx
index 0a4cefb7e94..37e7b32f920 100644
--- a/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx
@@ -3,11 +3,8 @@ import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import type { NavLink } from '~/common';
-let mockShowMarketplace = true;
-
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
- useShowMarketplace: () => mockShowMarketplace,
}));
jest.mock('~/hooks/useKeyboardShortcuts', () => ({
@@ -35,6 +32,11 @@ jest.mock('../Switcher', () => ({
default: () => ,
}));
+jest.mock('../NewChat', () => ({
+ __esModule: true,
+ default: () => ,
+}));
+
jest.mock('~/components/Nav/AccountSettings', () => ({
__esModule: true,
default: () => ,
@@ -50,7 +52,9 @@ const links = [] as NavLink[];
describe('mobile drawer header', () => {
it('claims the close identity while the drawer is open', () => {
- render(, { wrapper: MemoryRouter });
+ render(, {
+ wrapper: MemoryRouter,
+ });
const close = screen.getByTestId('close-sidebar-button');
expect(close).toHaveAttribute('id', 'close-sidebar-button');
@@ -63,7 +67,7 @@ describe('mobile drawer header', () => {
* would find one sitting off-viewport and act on it.
*/
it('gives up that identity once closed', () => {
- render(, {
+ render(, {
wrapper: MemoryRouter,
});
@@ -73,7 +77,9 @@ describe('mobile drawer header', () => {
/** The only close control while open, so its binding must be discoverable here. */
it('advertises the toggle shortcut on the close control', () => {
- render(, { wrapper: MemoryRouter });
+ render(, {
+ wrapper: MemoryRouter,
+ });
expect(screen.getByTestId('close-sidebar-button')).toHaveAttribute(
'aria-keyshortcuts',
@@ -82,7 +88,7 @@ describe('mobile drawer header', () => {
});
it('keeps the closed drawer out of the tab order', () => {
- render(, {
+ render(, {
wrapper: MemoryRouter,
});
@@ -96,18 +102,21 @@ describe('mobile drawer header', () => {
* outlasts it (the id does not exist until `expanded` commits).
*/
it('moves focus to the toggle when the drawer opens', () => {
- const { rerender } = render(, {
- wrapper: MemoryRouter,
- });
+ const { rerender } = render(
+ ,
+ {
+ wrapper: MemoryRouter,
+ },
+ );
expect(document.activeElement).toBe(document.body);
- rerender();
+ rerender();
expect(document.activeElement).toBe(screen.getByTestId('close-sidebar-button'));
});
it('never steals focus while closed', () => {
- render(, {
+ render(, {
wrapper: MemoryRouter,
});
@@ -115,39 +124,35 @@ describe('mobile drawer header', () => {
});
/**
- * The toggle mirrors the chat header's OpenSidebar — same icon, same shared
- * Button variant, same far-left slot — so the drawer reads as the one
- * persistent control flipping state rather than a new X appearing elsewhere.
+ * The toggle keeps the far-left slot and the icon it shares with the chat
+ * header's OpenSidebar, so the drawer reads as the one persistent control
+ * flipping state rather than a new X appearing elsewhere. It wears `ghost`
+ * rather than `header-action`, whose bordered plate is drawn for a scrolling
+ * chat header and reads as an odd box beside the flat icons here.
*/
- it('leads the row with the shared header-action toggle', () => {
- const { container } = render(, {
- wrapper: MemoryRouter,
- });
+ it('leads the row with a toggle styled like its neighbours', () => {
+ const { container } = render(
+ ,
+ {
+ wrapper: MemoryRouter,
+ },
+ );
const toggle = screen.getByTestId('close-sidebar-button');
expect(container.firstElementChild?.firstElementChild).toBe(toggle);
- expect(toggle).toHaveAttribute('variant', 'header-action');
+ expect(toggle).toHaveAttribute('variant', 'ghost');
expect(toggle.querySelector('[data-testid="sidebar-icon"]')).not.toBeNull();
});
- it('keeps an Agent Marketplace entry reachable from the drawer', () => {
- /* The drawer is the only sidebar surface on small screens, so losing this
- * entry here leaves marketplace users with no sidebar route to `/agents`. */
- render(, {
- wrapper: MemoryRouter,
- });
-
- const marketplace = screen.getByTestId('nav-agents-marketplace-button');
- expect(marketplace).toHaveAttribute('href', '/agents');
- });
-
- it('omits the marketplace entry without marketplace access', () => {
- mockShowMarketplace = false;
- render(, {
+ /** New chat took the marketplace icon's slot. It belongs beside the panel
+ * switcher because it means the same thing whichever panel is showing, which
+ * is exactly why it no longer repeats under each panel's contents. */
+ it('carries new chat in the strip, and not the marketplace', () => {
+ render(, {
wrapper: MemoryRouter,
});
+ expect(screen.getByTestId('nav-new-chat-fab')).toBeInTheDocument();
expect(screen.queryByTestId('nav-agents-marketplace-button')).not.toBeInTheDocument();
- mockShowMarketplace = true;
});
});
diff --git a/client/src/components/UnifiedSidebar/mobile/__tests__/NewChat.spec.tsx b/client/src/components/UnifiedSidebar/mobile/__tests__/NewChat.spec.tsx
new file mode 100644
index 00000000000..4a6cbd5e083
--- /dev/null
+++ b/client/src/components/UnifiedSidebar/mobile/__tests__/NewChat.spec.tsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import { RecoilRoot } from 'recoil';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, cleanup, render, screen, fireEvent } from '@testing-library/react';
+jest.mock('~/hooks', () => ({
+ useLocalize: () => (key: string) => key,
+}));
+
+jest.mock('~/hooks/useKeyboardShortcuts', () => ({
+ useShortcutAriaKey: () => 'Meta+Shift+O',
+}));
+
+jest.mock('@librechat/client', () => ({
+ Button: ({
+ children,
+ asChild: _asChild,
+ ...props
+ }: {
+ children: React.ReactNode;
+ asChild?: boolean;
+ }) =>
{children}
,
+ TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
+ /** The real `useNewChat` pulls the file-deletion mutation, which reads the toast context. */
+ useToastContext: () => ({ showToast: jest.fn() }),
+}));
+
+jest.mock('~/Providers', () => ({
+ useActivePanel: () => ({ active: 'conversations', setActive: jest.fn() }),
+ resolveActivePanel: () => 'conversations',
+ DEFAULT_PANEL: 'conversations',
+}));
+
+/** The heavy reset itself is the unit under order-test; only the navigation
+ * internals below useNewChat are stubbed. */
+const mockNewConversation = jest.fn();
+jest.mock('~/hooks/useNewConvo', () => ({
+ __esModule: true,
+ default: () => ({ newConversation: mockNewConversation }),
+}));
+
+import NewChat from '../NewChat';
+
+let queryClient: QueryClient;
+
+describe('mobile header new chat', () => {
+ beforeEach(() => {
+ queryClient = new QueryClient();
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ cleanup();
+ queryClient.clear();
+ });
+
+ /**
+ * The drawer close must start BEFORE the conversation reset: run
+ * synchronously, the reset's cache clearing and navigation flush in the
+ * tap's task and stall the slide's first frame. The reset rides the
+ * close's `afterSlide` callback instead.
+ */
+ it('closes the drawer first and defers the conversation reset to afterSlide', () => {
+ const order: string[] = [];
+ let afterSlide: (() => void) | undefined;
+ const onNewChat = jest.fn((callback?: () => void) => {
+ order.push('close');
+ afterSlide = callback;
+ });
+ mockNewConversation.mockImplementation(() => {
+ order.push('reset');
+ });
+
+ render(
+
+
+
+
+ ,
+ );
+ fireEvent.click(screen.getByTestId('nav-new-chat-fab'));
+
+ expect(order).toEqual(['close']);
+ expect(afterSlide).toBeDefined();
+
+ act(() => afterSlide?.());
+ expect(order).toEqual(['close', 'reset']);
+ });
+
+ it('leaves modified clicks to the browser (new tab)', () => {
+ const onNewChat = jest.fn();
+ render(
+
+
+
+
+ ,
+ );
+ fireEvent.click(screen.getByTestId('nav-new-chat-fab'), { ctrlKey: true });
+
+ expect(onNewChat).not.toHaveBeenCalled();
+ expect(mockNewConversation).not.toHaveBeenCalled();
+ });
+});
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index dfcb80aa3a5..8226a18656c 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -1180,6 +1180,11 @@
"count": 2
}
},
+ "client/src/components/Nav/AgentMarketplaceButton.tsx": {
+ "shadcn/no-restyle": {
+ "count": 1
+ }
+ },
"client/src/components/Nav/ExportConversation/ExportModal.tsx": {
"shadcn/no-restyle": {
"count": 3
@@ -2096,9 +2101,6 @@
"client/src/components/UnifiedSidebar/mobile/BottomBar.tsx": {
"shadcn/no-inline-styles": {
"count": 1
- },
- "shadcn/no-restyle": {
- "count": 4
}
},
"client/src/components/UnifiedSidebar/mobile/Scrim.tsx": {
From d60af397e7b27c06b270f74894cfa42cea35f9d2 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 09:43:35 +0200
Subject: [PATCH 2/4] fix(client): Keep the mobile drawer clear of the home
indicator and apart from its scrim
The footer now renders a safe-area spacer when search is absent, so panel
content no longer runs under the system gesture area on panels with nothing
to search. The marketplace row uses the Button's `row` size instead of
restyling it, which drops its new lint suppression.
The scrim paints the theme's `bg-scrim` role (80% by default) on its own
layer, which separates the drawer by 3:1 in both light palettes, and in dark
mode the drawer draws a `border-xheavy` edge, since no scrim opacity
separates two near-black surfaces. The scrim's focus ring is checked in the
browser instead of by class name.
---
.../components/Nav/AgentMarketplaceButton.tsx | 4 +-
.../UnifiedSidebar/UnifiedSidebar.tsx | 10 +-
.../UnifiedSidebar/mobile/BottomBar.tsx | 23 +-
.../UnifiedSidebar/mobile/NewChat.tsx | 2 +-
.../UnifiedSidebar/mobile/Scrim.tsx | 10 +-
.../mobile/__tests__/BottomBar.spec.tsx | 15 +-
.../mobile/__tests__/Scrim.spec.tsx | 16 -
.../scenarios/mobile-drawer-controls.spec.ts | 341 ++++++++++++++++++
eslint-suppressions.json | 7 +-
9 files changed, 382 insertions(+), 46 deletions(-)
create mode 100644 e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts
diff --git a/client/src/components/Nav/AgentMarketplaceButton.tsx b/client/src/components/Nav/AgentMarketplaceButton.tsx
index df33d69d408..7729cfbc0e0 100644
--- a/client/src/components/Nav/AgentMarketplaceButton.tsx
+++ b/client/src/components/Nav/AgentMarketplaceButton.tsx
@@ -45,8 +45,8 @@ export default function AgentMarketplaceButton({
);
}
diff --git a/client/src/components/UnifiedSidebar/mobile/__tests__/BottomBar.spec.tsx b/client/src/components/UnifiedSidebar/mobile/__tests__/BottomBar.spec.tsx
index fc39ac33ffc..88e7091cf9c 100644
--- a/client/src/components/UnifiedSidebar/mobile/__tests__/BottomBar.spec.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/__tests__/BottomBar.spec.tsx
@@ -47,18 +47,21 @@ describe('mobile bottom bar', () => {
expect(screen.queryByTestId('nav-new-chat-fab')).not.toBeInTheDocument();
});
- /** Searching messages only means anything from the conversation list, and an
- * empty footer would still take its padding out of the list above it. */
- it('stands down on a panel that has nothing to search', () => {
+ /** Searching messages only means anything from the conversation list. What
+ * remains is an empty spacer for the bottom safe-area inset, so the panel
+ * above still stops short of the home indicator. */
+ it('stands down to the safe-area spacer on a panel that has nothing to search', () => {
activePanel.active = 'prompts';
const { container } = renderBar();
- expect(container).toBeEmptyDOMElement();
+ expect(screen.queryByTestId('search-bar')).not.toBeInTheDocument();
+ expect(container.firstElementChild).toBeEmptyDOMElement();
});
- it('stands down where the deployment has search off', () => {
+ it('stands down to the safe-area spacer where the deployment has search off', () => {
const { container } = renderBar(false);
- expect(container).toBeEmptyDOMElement();
+ expect(screen.queryByTestId('search-bar')).not.toBeInTheDocument();
+ expect(container.firstElementChild).toBeEmptyDOMElement();
});
});
diff --git a/client/src/components/UnifiedSidebar/mobile/__tests__/Scrim.spec.tsx b/client/src/components/UnifiedSidebar/mobile/__tests__/Scrim.spec.tsx
index 6e97567d8f7..8002e1ecbda 100644
--- a/client/src/components/UnifiedSidebar/mobile/__tests__/Scrim.spec.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/__tests__/Scrim.spec.tsx
@@ -46,22 +46,6 @@ describe('mobile drawer Scrim', () => {
expect(scrim).toHaveClass('pointer-events-none');
});
- /**
- * The parent shell is overflow-hidden and the global :focus-visible outline
- * sits 2px outside the box, so an inset ring is the only indicator that
- * actually paints.
- */
- it('keeps the focus ring inside the overflow-hidden shell', () => {
- const { scrim } = setup({ expanded: true });
-
- expect(scrim).toHaveClass(
- 'focus-visible:outline-hidden',
- 'focus-visible:ring-2',
- 'focus-visible:ring-inset',
- 'focus-visible:ring-focus-control',
- );
- });
-
/**
* The state commits before the drawer and pane finish moving, so releasing
* the pointer target early lets a tap through to a control sliding by
diff --git a/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts b/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts
new file mode 100644
index 00000000000..7f3efd0b258
--- /dev/null
+++ b/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts
@@ -0,0 +1,341 @@
+import { expect, test } from '@playwright/test';
+import type { Page } from '@playwright/test';
+import { clickHouseTheme } from '../../../../packages/client/src/theme/themes/clickhouse';
+import { NEW_CHAT_PATH } from '../helpers';
+
+/**
+ * The mobile drawer keeps one place for each control: new chat sits in the header strip beside
+ * the panel switcher, the marketplace is a labelled row above the panel content, and the footer
+ * holds search alone. The drawer also has to read as a surface of its own over the conversation
+ * it covers, in every bundled palette: the scrim separates it in light, and in dark (where the
+ * scrim and the drawer are both near-black) the drawer draws its own edge.
+ *
+ * Every scenario loads at phone width rather than resizing into it: a desktop-to-phone resize
+ * leaves the drawer on the desktop toggle path (berry-13/LibreChat#205).
+ */
+
+const DRAWER = '#mobile-drawer';
+const SCRIM = '#mobile-drawer-scrim';
+/** The layer inside the scrim button that paints the theme's scrim role. */
+const SCRIM_FILL = `${SCRIM} > span`;
+/** WCAG 1.4.11: a boundary that carries meaning needs 3:1 against its surround. */
+const BOUNDARY_CONTRAST = 3;
+/** WCAG 1.4.3: body text needs 4.5:1 against its background. */
+const TEXT_CONTRAST = 4.5;
+
+type Mode = 'light' | 'dark';
+type Rgb = [number, number, number];
+
+test.use({ viewport: { width: 390, height: 844 } });
+
+/**
+ * `strip` turns on the setting that leaves a strip of conversation beside the drawer. Only then is
+ * there a scrim, and something for the drawer to stand apart from: by default it covers the
+ * whole width.
+ */
+async function openDrawer(
+ page: Page,
+ mode: Mode = 'light',
+ definition?: { name: string },
+ strip = false,
+) {
+ await page.addInitScript(
+ ([appearance, stored, withStrip]) => {
+ localStorage.setItem('mobileDrawerStrip', JSON.stringify(withStrip));
+ localStorage.setItem('color-theme', appearance as string);
+ localStorage.removeItem('theme-colors');
+ localStorage.removeItem('theme-name');
+ if (stored) {
+ localStorage.setItem('theme-definition', JSON.stringify(stored));
+ localStorage.setItem('theme-source', 'definition');
+ } else {
+ localStorage.removeItem('theme-definition');
+ localStorage.removeItem('theme-source');
+ }
+ },
+ [mode, definition ?? null, strip] as [string, unknown, boolean],
+ );
+ await page.goto(NEW_CHAT_PATH, { timeout: 15000 });
+ await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible({
+ timeout: 30000,
+ });
+ const root = page.locator('html');
+ await expect(root).toHaveClass(mode === 'dark' ? /\bdark\b/ : /\blight\b/);
+ if (definition) {
+ await expect(root).toHaveAttribute('data-theme', definition.name);
+ }
+
+ const drawer = page.locator(DRAWER);
+ if (!(await drawer.getByTestId('close-sidebar-button').isVisible())) {
+ await page.getByTestId('header-open-sidebar-button').click();
+ }
+ await expect(drawer.getByTestId('close-sidebar-button')).toBeVisible();
+ await expect(drawer).not.toHaveAttribute('inert');
+ /** The slide has to land before anything is measured against the viewport. */
+ await expect.poll(async () => (await drawer.boundingBox())?.x ?? -1, { timeout: 5000 }).toBe(0);
+ return drawer;
+}
+
+function parseRgb(color: string): { rgb: Rgb; alpha: number } {
+ const parts = color.match(/[\d.]+/g)?.map(Number) ?? [];
+ return { rgb: [parts[0], parts[1], parts[2]], alpha: parts.length > 3 ? parts[3] : 1 };
+}
+
+function luminance([r, g, b]: Rgb) {
+ const channel = (value: number) => {
+ const c = value / 255;
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ };
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
+}
+
+function contrast(a: Rgb, b: Rgb) {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+function over(top: string, bottom: string): Rgb {
+ const { rgb, alpha } = parseRgb(top);
+ const base = parseRgb(bottom).rgb;
+ return rgb.map((value, i) => value * alpha + base[i] * (1 - alpha)) as Rgb;
+}
+
+/**
+ * The drawer against the conversation it covers, read from the computed styles: the drawer's
+ * own fill, the page under the scrim with the scrim composited on top, and the drawer's
+ * trailing edge if it draws one.
+ */
+async function readBoundary(page: Page) {
+ const styles = await page.evaluate(
+ ([drawerSelector, scrimSelector]) => {
+ const drawer = document.querySelector(drawerSelector) as HTMLElement;
+ const scrim = document.querySelector(scrimSelector) as HTMLElement;
+ const drawerStyle = getComputedStyle(drawer);
+ /** The scrim sits over the chat pane, whose surface is the body's. */
+ const pageFill = getComputedStyle(document.body).backgroundColor;
+ return {
+ drawer: drawerStyle.backgroundColor,
+ edgeWidth: parseFloat(drawerStyle.borderRightWidth),
+ edgeColor: drawerStyle.borderRightColor,
+ scrim: getComputedStyle(scrim).backgroundColor,
+ page: pageFill,
+ };
+ },
+ [DRAWER, SCRIM_FILL],
+ );
+ const drawer = parseRgb(styles.drawer).rgb;
+ const dimmed = over(styles.scrim, styles.page);
+ const fill = contrast(drawer, dimmed);
+ const edge =
+ styles.edgeWidth >= 1
+ ? Math.max(
+ contrast(over(styles.edgeColor, styles.drawer), drawer),
+ contrast(over(styles.edgeColor, styles.drawer), dimmed),
+ )
+ : 0;
+ return { fill, edge, scrim: styles.scrim };
+}
+
+test.describe('mobile drawer controls', () => {
+ test('new chat sits beside the panel switcher and starts a chat from the drawer @scenario:mobile-drawer-new-chat-in-header', async ({
+ page,
+ }) => {
+ const drawer = await openDrawer(page);
+ const newChat = drawer.getByRole('link', { name: 'New chat' });
+ await expect(newChat).toHaveCount(1);
+ await expect(newChat).toBeVisible();
+
+ const switcher = await drawer.getByTestId('panel-switcher-button').boundingBox();
+ const button = await newChat.boundingBox();
+ const close = await drawer.getByTestId('close-sidebar-button').boundingBox();
+ expect(switcher && button && close).toBeTruthy();
+ /** Same strip as the switcher and the close toggle: their vertical centers line up. */
+ const center = (box: { y: number; height: number }) => box.y + box.height / 2;
+ expect(Math.abs(center(button!) - center(switcher!))).toBeLessThanOrEqual(2);
+ expect(Math.abs(center(button!) - center(close!))).toBeLessThanOrEqual(2);
+ expect(button!.x).toBeGreaterThanOrEqual(switcher!.x + switcher!.width - 1);
+
+ await page.keyboard.press('Shift');
+ await newChat.focus();
+ await page.keyboard.press('Enter');
+ await expect(page).toHaveURL(/\/c\/new$/);
+ await expect(drawer).toHaveAttribute('inert', '');
+ });
+
+ test('the marketplace is a labelled row above the panels that stays while they change @scenario:mobile-drawer-marketplace-row', async ({
+ page,
+ }) => {
+ const drawer = await openDrawer(page);
+ const marketplace = drawer.getByRole('link', { name: 'Agent Marketplace' });
+ await expect(marketplace).toBeVisible();
+ await expect(marketplace).toHaveText('Agent Marketplace');
+
+ const nav = drawer.locator('#chat-history-nav');
+ const before = await marketplace.boundingBox();
+ const navBox = await nav.boundingBox();
+ expect(before && navBox).toBeTruthy();
+ expect(before!.y + before!.height).toBeLessThanOrEqual(navBox!.y + 1);
+ /** A full-width row, not an icon in the header strip. */
+ expect(before!.width).toBeGreaterThan(200);
+
+ await drawer.getByTestId('panel-switcher-button').click();
+ const panels = page.getByRole('menuitemcheckbox');
+ await expect(panels.first()).toBeVisible();
+ const choices = await panels.count();
+ expect(choices).toBeGreaterThan(1);
+ await panels.nth(1).click();
+ await expect(page.getByRole('menuitemcheckbox')).toHaveCount(0);
+
+ const after = await marketplace.boundingBox();
+ expect(after).toEqual(before);
+
+ await marketplace.click();
+ await expect(page).toHaveURL(/\/agents/);
+ await expect(drawer).toHaveAttribute('inert', '');
+ });
+
+ test('without the marketplace the panels start right under the header strip @scenario:mobile-drawer-no-marketplace-no-gap', async ({
+ page,
+ }) => {
+ /** The role this page reads, with the marketplace permission off; nothing shared changes. */
+ await page.route('**/api/roles/*', async (route) => {
+ const response = await route.fetch();
+ const role = await response.json();
+ role.permissions = {
+ ...role.permissions,
+ MARKETPLACE: { ...role.permissions?.MARKETPLACE, USE: false },
+ };
+ await route.fulfill({ response, json: role });
+ });
+ const drawer = await openDrawer(page);
+ await expect(drawer.getByTestId('nav-agents-marketplace-button')).toHaveCount(0);
+
+ const header = await drawer.getByTestId('close-sidebar-button').locator('..').boundingBox();
+ const nav = await drawer.locator('#chat-history-nav').boundingBox();
+ expect(header && nav).toBeTruthy();
+ expect(Math.abs(nav!.y - (header!.y + header!.height))).toBeLessThanOrEqual(1);
+ });
+
+ test('the drawer footer holds only search and stands down where there is nothing to search @scenario:mobile-drawer-footer-search-only', async ({
+ page,
+ }) => {
+ const drawer = await openDrawer(page);
+ /** New chat left the footer for the header strip: one copy in the drawer, not two. */
+ await expect(drawer.getByRole('link', { name: 'New chat' })).toHaveCount(1);
+ await expect(drawer.getByTestId('nav-new-chat-fab')).toHaveCount(1);
+
+ await drawer.getByTestId('panel-switcher-button').click();
+ const panels = page.getByRole('menuitemcheckbox');
+ await expect(panels.first()).toBeVisible();
+ await panels.nth(1).click();
+ await expect(page.getByRole('menuitemcheckbox')).toHaveCount(0);
+
+ /** No footer beyond the safe-area inset (zero here): the panel runs to the drawer's bottom. */
+ await expect(drawer.getByRole('searchbox')).toHaveCount(0);
+ const nav = await drawer.locator('#chat-history-nav').boundingBox();
+ const box = await drawer.boundingBox();
+ expect(nav && box).toBeTruthy();
+ expect(box!.y + box!.height - (nav!.y + nav!.height)).toBeLessThanOrEqual(1);
+ });
+
+ /** Each tag is written out whole: the runner finds a scenario by its literal tag. */
+ const DARK_TEXT_CASES: Array<{ title: string; definition?: { name: string } }> = [
+ {
+ title:
+ 'the drawer header and marketplace row read in the default dark theme @scenario:mobile-drawer-text-readable-dark-default',
+ },
+ {
+ title:
+ 'the drawer header and marketplace row read in the ClickHouse dark theme @scenario:mobile-drawer-text-readable-dark-clickhouse',
+ definition: clickHouseTheme,
+ },
+ ];
+
+ for (const { title, definition } of DARK_TEXT_CASES) {
+ test(title, async ({ page }) => {
+ const drawer = await openDrawer(page, 'dark', definition);
+ const colors = await drawer.evaluate((node) => {
+ const label = Array.from(
+ node.querySelectorAll('a[data-testid="nav-agents-marketplace-button"] span'),
+ )[0];
+ return {
+ surface: getComputedStyle(node).backgroundColor,
+ drawerText: getComputedStyle(node).color,
+ label: label ? getComputedStyle(label).color : null,
+ };
+ });
+ expect(colors.label).not.toBeNull();
+ const surface = parseRgb(colors.surface).rgb;
+ expect(contrast(parseRgb(colors.drawerText).rgb, surface)).toBeGreaterThanOrEqual(
+ TEXT_CONTRAST,
+ );
+ expect(contrast(parseRgb(colors.label!).rgb, surface)).toBeGreaterThanOrEqual(TEXT_CONTRAST);
+ });
+ }
+
+ /** Each tag is written out whole: the runner finds a scenario by its literal tag. */
+ const BOUNDARY_CASES: Array<{ title: string; mode: Mode; definition?: { name: string } }> = [
+ {
+ title:
+ 'the drawer stands apart from the dimmed chat in the default light theme @scenario:mobile-drawer-boundary-default-light',
+ mode: 'light',
+ },
+ {
+ title:
+ 'the drawer stands apart from the dimmed chat in the default dark theme @scenario:mobile-drawer-boundary-default-dark',
+ mode: 'dark',
+ },
+ {
+ title:
+ 'the drawer stands apart from the dimmed chat in the ClickHouse light theme @scenario:mobile-drawer-boundary-clickhouse-light',
+ mode: 'light',
+ definition: clickHouseTheme,
+ },
+ {
+ title:
+ 'the drawer stands apart from the dimmed chat in the ClickHouse dark theme @scenario:mobile-drawer-boundary-clickhouse-dark',
+ mode: 'dark',
+ definition: clickHouseTheme,
+ },
+ ];
+
+ for (const { title, mode, definition } of BOUNDARY_CASES) {
+ test(title, async ({ page }) => {
+ await openDrawer(page, mode, definition, true);
+ await expect(page.locator(SCRIM)).toBeVisible();
+ const { fill, edge } = await readBoundary(page);
+ /** Either the fill against the scrim or the drawer's own edge has to carry it. */
+ expect(Math.max(fill, edge)).toBeGreaterThanOrEqual(BOUNDARY_CONTRAST);
+ if (mode === 'light') {
+ /** The scrim does the work in light: no extra line on a surface that already reads. */
+ expect(fill).toBeGreaterThanOrEqual(BOUNDARY_CONTRAST);
+ expect(edge).toBe(0);
+ }
+ });
+ }
+
+ test('the scrim follows the theme scrim role and shows its focus inside the shell @scenario:mobile-drawer-scrim-role-and-focus', async ({
+ page,
+ }) => {
+ await openDrawer(page, 'light', undefined, true);
+ const scrim = page.locator(SCRIM);
+ /** The dialogs' theme-owned role: surface-overlay at the theme's scrim opacity. */
+ const role = await page.evaluate(() => {
+ const probe = document.createElement('div');
+ probe.className = 'bg-scrim';
+ document.body.append(probe);
+ const color = getComputedStyle(probe).backgroundColor;
+ probe.remove();
+ return color;
+ });
+ await expect(page.locator(SCRIM_FILL)).toHaveCSS('background-color', role);
+
+ /** Keyboard modality, so `:focus-visible` matches as it does for a Tab. */
+ await page.keyboard.press('Shift');
+ await scrim.focus();
+ await expect(scrim).toBeFocused();
+ /** The shell is overflow-hidden, so only an inset indicator actually paints. */
+ const shadow = await scrim.evaluate((node) => getComputedStyle(node).boxShadow);
+ expect(shadow).toContain('inset');
+ });
+});
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index 8226a18656c..8fffbf15c1e 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -1180,11 +1180,6 @@
"count": 2
}
},
- "client/src/components/Nav/AgentMarketplaceButton.tsx": {
- "shadcn/no-restyle": {
- "count": 1
- }
- },
"client/src/components/Nav/ExportConversation/ExportModal.tsx": {
"shadcn/no-restyle": {
"count": 3
@@ -2108,7 +2103,7 @@
"count": 2
},
"shadcn/no-restyle": {
- "count": 4
+ "count": 3
}
},
"client/src/components/Variables/Editor.tsx": {
From 3c0121a7b5f4c35f7a9be2c2aa95ecc6a66240cc Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 10:17:58 +0200
Subject: [PATCH 3/4] fix(client): Paint the mobile scrim's focus indicator
above its fill
The scrim's theme fill sits on its own layer, which painted over the
button's inset focus ring, so a keyboard user focusing the scrim with the
chat strip on saw almost none of it. The indicator now draws on a layer
above the fill, in two tones: at the theme's 80% scrim a single dark ring
reaches only 2.3:1 on the light palettes' mid-gray, so a surface band
carries it in light and the focus ring in dark. The scenarios check, in
all four palettes, that the topmost layer at the scrim's edge carries an
indicator with a tone at 3:1 against the fill.
---
.../UnifiedSidebar/mobile/Scrim.tsx | 14 ++-
.../scenarios/mobile-drawer-controls.spec.ts | 95 ++++++++++++++-----
eslint-suppressions.json | 2 +-
3 files changed, 82 insertions(+), 29 deletions(-)
diff --git a/client/src/components/UnifiedSidebar/mobile/Scrim.tsx b/client/src/components/UnifiedSidebar/mobile/Scrim.tsx
index 3826f0bb0eb..2fc3b59c503 100644
--- a/client/src/components/UnifiedSidebar/mobile/Scrim.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/Scrim.tsx
@@ -39,10 +39,7 @@ export default function Scrim({
tabIndex={expanded ? 0 : -1}
aria-hidden={!expanded || undefined}
className={cn(
- 'absolute inset-0 rounded-none',
- /** Inset: the shell is overflow-hidden, and the shared ring's offset
- * puts it 2px outside the box, which the shell would clip. */
- 'focus-visible:ring-offset-0 focus-visible:ring-inset',
+ 'group absolute inset-0 rounded-none',
!expanded && !isSliding && 'pointer-events-none',
)}
style={{
@@ -57,6 +54,15 @@ export default function Scrim({
{/* The dialogs' theme-owned scrim role, painted on its own layer so the
button itself carries no color of its own. */}
+ {/* The focus indicator, on a layer above the fill: the button's own ring
+ paints with its background, under the fill. Inset, because the shell
+ is overflow-hidden and would clip a ring drawn outside the box. Two
+ tones, because the fill is mid-gray in a light palette and black in a
+ dark one: the surface band carries it in light, the ring in dark. */}
+
);
}
diff --git a/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts b/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts
index 7f3efd0b258..f5e52d3fff1 100644
--- a/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts
+++ b/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts
@@ -17,7 +17,7 @@ import { NEW_CHAT_PATH } from '../helpers';
const DRAWER = '#mobile-drawer';
const SCRIM = '#mobile-drawer-scrim';
/** The layer inside the scrim button that paints the theme's scrim role. */
-const SCRIM_FILL = `${SCRIM} > span`;
+const SCRIM_FILL = `${SCRIM} > span:first-child`;
/** WCAG 1.4.11: a boundary that carries meaning needs 3:1 against its surround. */
const BOUNDARY_CONTRAST = 3;
/** WCAG 1.4.3: body text needs 4.5:1 against its background. */
@@ -314,28 +314,75 @@ test.describe('mobile drawer controls', () => {
});
}
- test('the scrim follows the theme scrim role and shows its focus inside the shell @scenario:mobile-drawer-scrim-role-and-focus', async ({
- page,
- }) => {
- await openDrawer(page, 'light', undefined, true);
- const scrim = page.locator(SCRIM);
- /** The dialogs' theme-owned role: surface-overlay at the theme's scrim opacity. */
- const role = await page.evaluate(() => {
- const probe = document.createElement('div');
- probe.className = 'bg-scrim';
- document.body.append(probe);
- const color = getComputedStyle(probe).backgroundColor;
- probe.remove();
- return color;
- });
- await expect(page.locator(SCRIM_FILL)).toHaveCSS('background-color', role);
+ /** Each tag is written out whole: the runner finds a scenario by its literal tag. */
+ const FOCUS_CASES: Array<{ title: string; mode: Mode; definition?: { name: string } }> = [
+ {
+ title:
+ 'the scrim follows the theme scrim role and shows its focus in the default light theme @scenario:mobile-drawer-scrim-focus-default-light',
+ mode: 'light',
+ },
+ {
+ title:
+ 'the scrim follows the theme scrim role and shows its focus in the default dark theme @scenario:mobile-drawer-scrim-focus-default-dark',
+ mode: 'dark',
+ },
+ {
+ title:
+ 'the scrim follows the theme scrim role and shows its focus in the ClickHouse light theme @scenario:mobile-drawer-scrim-focus-clickhouse-light',
+ mode: 'light',
+ definition: clickHouseTheme,
+ },
+ {
+ title:
+ 'the scrim follows the theme scrim role and shows its focus in the ClickHouse dark theme @scenario:mobile-drawer-scrim-focus-clickhouse-dark',
+ mode: 'dark',
+ definition: clickHouseTheme,
+ },
+ ];
- /** Keyboard modality, so `:focus-visible` matches as it does for a Tab. */
- await page.keyboard.press('Shift');
- await scrim.focus();
- await expect(scrim).toBeFocused();
- /** The shell is overflow-hidden, so only an inset indicator actually paints. */
- const shadow = await scrim.evaluate((node) => getComputedStyle(node).boxShadow);
- expect(shadow).toContain('inset');
- });
+ for (const { title, mode, definition } of FOCUS_CASES) {
+ test(title, async ({ page }) => {
+ await openDrawer(page, mode, definition, true);
+ const scrim = page.locator(SCRIM);
+ /** The dialogs' theme-owned role: surface-overlay at the theme's scrim opacity. */
+ const role = await page.evaluate(() => {
+ const probe = document.createElement('div');
+ probe.className = 'bg-scrim';
+ document.body.append(probe);
+ const color = getComputedStyle(probe).backgroundColor;
+ probe.remove();
+ return color;
+ });
+ await expect(page.locator(SCRIM_FILL)).toHaveCSS('background-color', role);
+
+ /** Keyboard modality, so `:focus-visible` matches as it does for a Tab. */
+ await page.keyboard.press('Shift');
+ await scrim.focus();
+ await expect(scrim).toBeFocused();
+ /**
+ * The shell is overflow-hidden, so only an inset indicator shows, and it has to paint above
+ * the fill. Hit-testing follows paint order, so the topmost layer just inside the scrim's
+ * edge must be the one drawing the inset indicator, and one of its tones has to stand out
+ * from the fill.
+ */
+ const top = await scrim.evaluate((node) => {
+ const box = node.getBoundingClientRect();
+ const hit = document.elementsFromPoint(box.right - 1, box.top + box.height / 2)[0];
+ const fill = node.querySelector(':scope > span:first-child');
+ return {
+ inside: node.contains(hit),
+ shadow: hit ? getComputedStyle(hit).boxShadow : '',
+ fill: fill ? getComputedStyle(fill).backgroundColor : '',
+ page: getComputedStyle(document.body).backgroundColor,
+ };
+ });
+ expect(top.inside).toBe(true);
+ expect(top.shadow).toContain('inset');
+ const tones = top.shadow.match(/rgba?\([^)]*\)/g) ?? [];
+ expect(tones.length).toBeGreaterThan(0);
+ const dimmed = over(top.fill, top.page);
+ const best = Math.max(...tones.map((tone) => contrast(parseRgb(tone).rgb, dimmed)));
+ expect(best).toBeGreaterThanOrEqual(BOUNDARY_CONTRAST);
+ });
+ }
});
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index 8fffbf15c1e..dfb30853e2d 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -2103,7 +2103,7 @@
"count": 2
},
"shadcn/no-restyle": {
- "count": 3
+ "count": 1
}
},
"client/src/components/Variables/Editor.tsx": {
From 152bb9a0404fab97cc8761dcfd74884bcf823346 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Thu, 1 Oct 2026 10:54:04 +0200
Subject: [PATCH 4/4] fix(client): Keep the mobile drawer close toggle on the
header's control
The drawer's close toggle is the chat header's open toggle flipping state,
so it keeps the header-action surface and size it shares with that control
rather than going flat like its new neighbours.
---
.../src/components/UnifiedSidebar/mobile/Header.tsx | 7 ++-----
.../UnifiedSidebar/mobile/__tests__/Header.spec.tsx | 12 +++++-------
2 files changed, 7 insertions(+), 12 deletions(-)
diff --git a/client/src/components/UnifiedSidebar/mobile/Header.tsx b/client/src/components/UnifiedSidebar/mobile/Header.tsx
index 966d8295417..fd03602658e 100644
--- a/client/src/components/UnifiedSidebar/mobile/Header.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/Header.tsx
@@ -59,17 +59,14 @@ function Header({
id={expanded ? CLOSE_SIDEBAR_ID : undefined}
data-testid={expanded ? 'close-sidebar-button' : undefined}
size="icon"
- /** `ghost`, like the controls it shares the strip with. `header-action`
- * carries the chat header's bordered plate, which reads over a scrolling
- * gradient and reads as an odd box beside two flat icons here. */
- variant="ghost"
+ variant="header-action"
aria-label={localize('com_nav_close_sidebar')}
aria-expanded={expanded}
aria-controls="chat-history-nav"
/** The only close control while open, so its binding must be discoverable here. */
aria-keyshortcuts={toggleSidebarAriaKey}
tabIndex={expanded ? 0 : -1}
- className="h-9 w-9 shrink-0"
+ className="shrink-0"
onClick={onClose}
>
diff --git a/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx b/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx
index 37e7b32f920..4f4f961ee50 100644
--- a/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx
@@ -124,13 +124,11 @@ describe('mobile drawer header', () => {
});
/**
- * The toggle keeps the far-left slot and the icon it shares with the chat
- * header's OpenSidebar, so the drawer reads as the one persistent control
- * flipping state rather than a new X appearing elsewhere. It wears `ghost`
- * rather than `header-action`, whose bordered plate is drawn for a scrolling
- * chat header and reads as an odd box beside the flat icons here.
+ * The toggle mirrors the chat header's OpenSidebar (same icon, same shared
+ * Button variant, same far-left slot), so the drawer reads as the one
+ * persistent control flipping state rather than a new X appearing elsewhere.
*/
- it('leads the row with a toggle styled like its neighbours', () => {
+ it('leads the row with the shared header-action toggle', () => {
const { container } = render(
,
{
@@ -140,7 +138,7 @@ describe('mobile drawer header', () => {
const toggle = screen.getByTestId('close-sidebar-button');
expect(container.firstElementChild?.firstElementChild).toBe(toggle);
- expect(toggle).toHaveAttribute('variant', 'ghost');
+ expect(toggle).toHaveAttribute('variant', 'header-action');
expect(toggle.querySelector('[data-testid="sidebar-icon"]')).not.toBeNull();
});