📱 perf: Instant Mobile View Switching + Uniform Sidebar Toggle (#14913)

* ⚡ perf: Stabilize the Assistants Map Context Value

* ⚡ perf: Start the Mobile Drawer Slide Before the State-Flip Commit

* 📱 style: Mirror the Header Sidebar Toggle in the Mobile Drawer

* 🧷 fix: Apply Reduced-Motion Flips Synchronously, Drop Stale Deferred Flips

* 🎨 refactor: Promote the Sidebar Toggle Look to a Button Variant

* 🧷 fix: Drive Focus and the Release Deadline From the Commit, Not Timers

* 🚪 fix: Route Every Sidebar Mutation Through the Animated Toggle

* 🛝 fix: Carry Navigation and Focus Past the Slide, Toggle the Latest Intent

* 🆕 fix: Slide Before the New-Chat Reset, Drop Superseded Deferred Flips
This commit is contained in:
Danny Avila 2026-08-17 01:28:42 -04:00 • committed by GitHub
parent 5ca667c258
commit 6eb2249620
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1146 additions and 101 deletions

View file

@ -1,10 +1,7 @@
import { startTransition } from 'react';
import { useSetRecoilState } from 'recoil';
import { TooltipAnchor, Button, Sidebar } from '@librechat/client';
import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts';
import useSidebarToggle from '~/hooks/Nav/useSidebarToggle';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
export const CLOSE_SIDEBAR_ID = 'close-sidebar-button';
export const OPEN_SIDEBAR_ID = 'open-sidebar-button';
@ -22,17 +19,21 @@ export default function OpenSidebar({
testId?: string;
}) {
const localize = useLocalize();
const setSidebarExpanded = useSetRecoilState(store.sidebarExpanded);
const { setSidebarOpen } = useSidebarToggle();
const tooltipDescription = useShortcutHint('toggleSidebar', localize('com_nav_open_sidebar'));
const ariaKey = useShortcutAriaKey('toggleSidebar');
const handleClick = () => {
startTransition(() => {
setSidebarExpanded(true);
});
setTimeout(() => {
document.getElementById(CLOSE_SIDEBAR_ID)?.focus();
}, 250);
const mode = setSidebarOpen(true);
if (mode === 'none') {
/** Desktop only: the expanded panel claims `CLOSE_SIDEBAR_ID` and has
* no commit-driven handoff of its own. The mobile drawer focuses its
* toggle from the commit itself — a second timer there would steal
* focus back from a keyboard user who has already tabbed onward. */
setTimeout(() => {
document.getElementById(CLOSE_SIDEBAR_ID)?.focus();
}, 250);
}
};
return (
@ -42,16 +43,13 @@ export default function OpenSidebar({
<Button
id={OPEN_SIDEBAR_ID}
size="icon"
variant="outline"
variant="header-action"
data-testid={testId}
aria-label={localize('com_nav_open_sidebar')}
aria-expanded={false}
aria-controls="chat-history-nav"
aria-keyshortcuts={ariaKey}
className={cn(
'rounded-xl bg-presentation duration-0 hover:bg-surface-active-alt',
className,
)}
className={className}
onClick={handleClick}
>
<Sidebar className="icon-md" aria-hidden="true" />

View file

@ -19,7 +19,7 @@ import store from '~/store';
interface ConversationProps {
conversation: TConversation;
retainView: () => void;
toggleNav: () => void;
toggleNav: (afterSlide?: () => void) => void;
isGenerating?: boolean;
}
@ -159,12 +159,15 @@ function Conversation({
return;
}
toggleNav();
/** The navigation rides `afterSlide`: run synchronously it flushes the
* conversation-switch commit in the tap's task, stalling the drawer's
* first frame — the exact delay the animated toggle exists to avoid. */
toggleNav(() => {
setDocumentTitle(title);
setDocumentTitle(title);
navigateToConvo(conversation, {
currentConvoId,
navigateToConvo(conversation, {
currentConvoId,
});
});
};

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { useMediaQuery } from '@librechat/client';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
import type { ConversationListResponse } from 'librechat-data-provider';
@ -20,6 +20,7 @@ import {
import ProjectsSection from '~/components/Conversations/ProjectsSection';
import PinnedSection from '~/components/Conversations/PinnedSection';
import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
import useSidebarToggle from '~/hooks/Nav/useSidebarToggle';
import { Conversations } from '~/components/Conversations';
import { collectPinnedConversations } from '~/utils';
import SearchBar from '~/components/Nav/SearchBar';
@ -30,7 +31,7 @@ const BookmarkNav = lazy(() => import('~/components/Nav/Bookmarks/BookmarkNav'))
const ConversationsSection = memo(() => {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const setSidebarExpanded = useSetRecoilState(store.sidebarExpanded);
const { setSidebarOpen } = useSidebarToggle();
const { isAuthenticated } = useAuthContext();
useTitleGeneration(isAuthenticated);
@ -97,11 +98,24 @@ const ConversationsSection = memo(() => {
[pinnedData?.conversations, conversations],
);
const toggleNav = useCallback(() => {
if (isSmallScreen) {
setSidebarExpanded(false);
}
}, [isSmallScreen, setSidebarExpanded]);
/**
* Selecting a conversation is the most common close path — it must take
* the animated route or the drawer stalls on the new conversation's
* commit before it starts sliding. `afterSlide` carries that navigation:
* run synchronously it would flush the conversation switch in the tap's
* task and stall the slide anyway; deferred, it lands mid-slide. Desktop
* runs it immediately (nothing slides).
*/
const toggleNav = useCallback(
(afterSlide?: () => void) => {
if (isSmallScreen) {
setSidebarOpen(false, afterSlide);
return;
}
afterSlide?.();
},
[isSmallScreen, setSidebarOpen],
);
const loadMoreConversations = useCallback(() => {
if (isFetchingNextPage || !computedHasNextPage) {

View file

@ -1,4 +1,4 @@
import { useCallback, useState, useEffect, useRef, memo, startTransition } from 'react';
import { useCallback, useState, useEffect, useRef, memo } from 'react';
import { useForm } from 'react-hook-form';
import type { ReactNode } from 'react';
import type { ChatFormValues } from '~/common';
@ -14,6 +14,7 @@ import {
import { ChatContext, ChatFormProvider, ActivePanelProvider } from '~/Providers';
import { MobileHeader, MobileBottomBar, MobileShortcutTargets } from './mobile';
import useUnifiedSidebarLinks from '~/hooks/Nav/useUnifiedSidebarLinks';
import useSidebarToggle from '~/hooks/Nav/useSidebarToggle';
import useSidebarState from '~/hooks/Nav/useSidebarState';
import { useChatHelpers, useLocalize } from '~/hooks';
import SidePanelNav from '~/components/SidePanel/Nav';
@ -43,24 +44,24 @@ function SidebarChatProvider({ children }: { children: ReactNode }) {
function UnifiedSidebar() {
const localize = useLocalize();
const { isSmallScreen, expanded, setExpanded } = useSidebarState();
const { isSmallScreen, expanded } = useSidebarState();
const { setSidebarOpen } = useSidebarToggle();
const [sidebarWidth, setSidebarWidth] = useState(getInitialWidth);
const [isResizing, setIsResizing] = useState(false);
const resizeHandlers = useRef<{ move: (e: MouseEvent) => void; up: () => void } | null>(null);
const links = useUnifiedSidebarLinks();
const handleCollapse = useCallback(() => {
startTransition(() => {
setExpanded(false);
});
}, [setExpanded]);
const handleCollapse = useCallback(
(afterSlide?: () => void) => {
setSidebarOpen(false, afterSlide);
},
[setSidebarOpen],
);
const handleExpand = useCallback(() => {
startTransition(() => {
setExpanded(true);
});
}, [setExpanded]);
setSidebarOpen(true);
}, [setSidebarOpen]);
const handleResizeStart = useCallback(() => {
setIsResizing(true);

View file

@ -16,20 +16,44 @@ import store from '~/store';
* rather than an overlay, so the virtualized list shrinks around it and can
* never be occluded.
*/
function BottomBar({ links, onNewChat }: { links: NavLink[]; onNewChat: () => void }) {
function BottomBar({
links,
onNewChat,
}: {
links: NavLink[];
onNewChat: (afterSlide?: () => void) => void;
}) {
const localize = useLocalize();
const search = useRecoilValue(store.search);
const switchToHistory = useRecoilValue(store.newChatSwitchToHistory);
const { active, setActive } = useActivePanel();
const handleNewChat = useCallback(() => {
if (switchToHistory) {
setActive(DEFAULT_PANEL);
}
onNewChat();
}, [switchToHistory, setActive, onNewChat]);
const { startNewChat } = useNewChat();
const { handleNewChatClick } = useNewChat({ onNewChat: handleNewChat });
/**
* 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');

View file

@ -1,6 +1,5 @@
import { memo, lazy, Suspense } from 'react';
import { X } from 'lucide-react';
import { Button, Skeleton } from '@librechat/client';
import { memo, lazy, Suspense, useEffect, useRef } from 'react';
import { Button, Sidebar, Skeleton } from '@librechat/client';
import type { NavLink } from '~/common';
import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar';
import { useShortcutAriaKey } from '~/hooks/useKeyboardShortcuts';
@ -11,8 +10,11 @@ const AccountSettings = lazy(() => import('~/components/Nav/AccountSettings'));
/**
* At full width there is nothing beside the drawer left to tap, so this close
* button is the primary dismissal. It keeps `CLOSE_SIDEBAR_ID` because
* `OpenSidebar` focuses that id shortly after opening.
* button is the primary dismissal. It mirrors the chat header's `OpenSidebar`
* toggle — same icon, same look, same far-left slot — so opening the drawer
* reads as the one persistent control flipping state, not a new X appearing
* elsewhere. It keeps `CLOSE_SIDEBAR_ID` because `OpenSidebar` focuses that id
* shortly after opening.
*/
function Header({
links,
@ -25,14 +27,22 @@ function Header({
}) {
const localize = useLocalize();
const toggleSidebarAriaKey = useShortcutAriaKey('toggleSidebar');
const closeRef = useRef<HTMLButtonElement>(null);
/** Opening makes the chat pane inert, so keyboard/AT focus must move into
* the drawer. The commit itself drives the handoff — every opener (button,
* swipe, shortcut) lands here, and a wall-clock timer would race the
* deferred state flip and silently miss whenever the flip outlasted it. */
useEffect(() => {
if (expanded) {
closeRef.current?.focus();
}
}, [expanded]);
return (
<div className="flex h-14 flex-shrink-0 items-center gap-2 border-b border-border-light px-2">
<Switcher links={links} />
<Suspense fallback={<Skeleton className="size-9 rounded-lg" />}>
<AccountSettings collapsed />
</Suspense>
<Button
ref={closeRef}
/**
* The drawer stays mounted while closed so it can slide, and a
* translated element still counts as visible. Only claim the close
@ -42,17 +52,22 @@ function Header({
id={expanded ? CLOSE_SIDEBAR_ID : undefined}
data-testid={expanded ? 'close-sidebar-button' : undefined}
size="icon"
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="size-10 flex-shrink-0 rounded-lg"
className="flex-shrink-0"
onClick={onClose}
>
<X className="size-5 text-text-primary" aria-hidden="true" />
<Sidebar className="icon-md" aria-hidden="true" />
</Button>
<Switcher links={links} />
<Suspense fallback={<Skeleton className="size-9 rounded-lg" />}>
<AccountSettings collapsed />
</Suspense>
</div>
);
}

View file

@ -0,0 +1,101 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
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;
}) => <div {...props}>{children}</div>,
}));
jest.mock('~/Providers', () => ({
useActivePanel: () => ({ active: 'conversations', setActive: jest.fn() }),
resolveActivePanel: () => 'conversations',
DEFAULT_PANEL: 'conversations',
}));
jest.mock('~/components/Nav/SearchBar', () => ({
__esModule: true,
default: () => <div data-testid="search-bar" />,
}));
/** 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';
const links = [] as NavLink[];
describe('mobile bottom bar — new chat ordering', () => {
beforeEach(() => {
jest.clearAllMocks();
});
/**
* 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(
<QueryClientProvider client={new QueryClient()}>
<RecoilRoot>
<BottomBar links={links} onNewChat={onNewChat} />
</RecoilRoot>
</QueryClientProvider>,
);
fireEvent.click(screen.getByTestId('nav-new-chat-fab'));
expect(order).toEqual(['close']);
expect(afterSlide).toBeDefined();
afterSlide?.();
expect(order).toEqual(['close', 'reset']);
});
it('leaves modified clicks to the browser (new tab)', () => {
const onNewChat = jest.fn();
render(
<QueryClientProvider client={new QueryClient()}>
<RecoilRoot>
<BottomBar links={links} onNewChat={onNewChat} />
</RecoilRoot>
</QueryClientProvider>,
);
fireEvent.click(screen.getByTestId('nav-new-chat-fab'), { ctrlKey: true });
expect(onNewChat).not.toHaveBeenCalled();
expect(mockNewConversation).not.toHaveBeenCalled();
});
});

View file

@ -1,3 +1,4 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import type { NavLink } from '~/common';
@ -10,9 +11,17 @@ jest.mock('~/hooks/useKeyboardShortcuts', () => ({
}));
jest.mock('@librechat/client', () => ({
Button: ({ children, ...props }: React.ComponentProps<'button'>) => (
<button {...props}>{children}</button>
),
Button: jest
.requireActual<typeof import('react')>('react')
.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'>
>(({ children, ...props }, ref) => (
<button ref={ref} {...props}>
{children}
</button>
)),
Sidebar: (props: React.ComponentProps<'svg'>) => <svg data-testid="sidebar-icon" {...props} />,
Skeleton: () => <div data-testid="skeleton" />,
}));
@ -70,4 +79,39 @@ describe('mobile drawer header', () => {
expect(screen.getByLabelText('com_nav_close_sidebar')).toHaveAttribute('tabindex', '-1');
});
/**
* Opening makes the chat pane inert, so keyboard/AT focus must move into
* the drawer. The commit itself drives the handoff — a wall-clock timer
* races the deferred state flip and silently misses when the flip
* outlasts it (the id does not exist until `expanded` commits).
*/
it('moves focus to the toggle when the drawer opens', () => {
const { rerender } = render(<Header links={links} expanded={false} onClose={jest.fn()} />);
expect(document.activeElement).toBe(document.body);
rerender(<Header links={links} expanded={true} onClose={jest.fn()} />);
expect(document.activeElement).toBe(screen.getByTestId('close-sidebar-button'));
});
it('never steals focus while closed', () => {
render(<Header links={links} expanded={false} onClose={jest.fn()} />);
expect(document.activeElement).toBe(document.body);
});
/**
* 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 the shared header-action toggle', () => {
const { container } = render(<Header links={links} expanded={true} onClose={jest.fn()} />);
const toggle = screen.getByTestId('close-sidebar-button');
expect(container.firstElementChild?.firstElementChild).toBe(toggle);
expect(toggle).toHaveAttribute('variant', 'header-action');
expect(toggle.querySelector('[data-testid="sidebar-icon"]')).not.toBeNull();
});
});

View file

@ -0,0 +1,62 @@
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EModelEndpoint, QueryKeys, dataService } from 'librechat-data-provider';
import useAgentsMap from '../useAgentsMap';
/** `dataService` methods are non-configurable, so the HTTP boundary is mocked
* at the module seam; everything else stays the real library. */
jest.mock('librechat-data-provider', () => {
const actual =
jest.requireActual<typeof import('librechat-data-provider')>('librechat-data-provider');
return {
...actual,
dataService: {
...actual.dataService,
listAgents: jest.fn(),
},
};
});
/**
* Root feeds this map straight into `AgentsMapContext.Provider`, so its
* referential stability decides whether every consumer — message rows,
* conversation icons, subagent cards — re-renders on unrelated Root renders
* (e.g. the mobile drawer toggle). Real react-query; only HTTP is stubbed.
*/
describe('useAgentsMap', () => {
const setup = () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
client.setQueryData([QueryKeys.endpoints], {
[EModelEndpoint.agents]: {},
});
(dataService.listAgents as jest.Mock).mockResolvedValue({
object: 'list',
data: [{ id: 'agent_1', name: 'Agent One' }],
first_id: 'agent_1',
last_id: 'agent_1',
has_more: false,
after: null,
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
return renderHook(() => useAgentsMap({ isAuthenticated: true }), { wrapper });
};
beforeEach(() => {
jest.clearAllMocks();
});
it('keeps the agents map referentially stable across unrelated re-renders', async () => {
const { result, rerender } = setup();
await waitFor(() => expect(result.current?.agent_1).toBeDefined());
const first = result.current;
rerender();
expect(result.current).toBe(first);
});
});

View file

@ -0,0 +1,82 @@
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EModelEndpoint, QueryKeys, dataService } from 'librechat-data-provider';
import useAssistantsMap from '../useAssistantsMap';
/** `dataService` methods are non-configurable, so the HTTP boundary is mocked
* at the module seam; everything else stays the real library. */
jest.mock('librechat-data-provider', () => {
const actual =
jest.requireActual<typeof import('librechat-data-provider')>('librechat-data-provider');
return {
...actual,
dataService: {
...actual.dataService,
listAssistants: jest.fn(),
},
};
});
/**
* Root feeds this map straight into `AssistantsMapContext.Provider`, so its
* referential stability decides whether every consumer — message rows
* included — re-renders on unrelated Root renders (e.g. the mobile drawer
* toggle). Real react-query against a seeded cache; only HTTP is stubbed.
*/
describe('useAssistantsMap', () => {
const listResponse = {
object: 'list',
data: [{ id: 'asst_1', name: 'Assistant One' }],
first_id: 'asst_1',
last_id: 'asst_1',
has_more: false,
};
const setup = (isAuthenticated: boolean) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
client.setQueryData([QueryKeys.endpoints], {
[EModelEndpoint.assistants]: { userProvide: false },
});
(dataService.listAssistants as jest.Mock).mockResolvedValue(listResponse);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
return renderHook(() => useAssistantsMap({ isAuthenticated }), { wrapper });
};
beforeEach(() => {
jest.clearAllMocks();
});
it('keeps the composite map referentially stable across unrelated re-renders', async () => {
const { result, rerender } = setup(true);
await waitFor(() => expect(result.current?.[EModelEndpoint.assistants]?.asst_1).toBeDefined());
const first = result.current;
rerender();
expect(result.current).toBe(first);
});
it('keeps each endpoint map stable across re-renders (memoized select)', async () => {
const { result, rerender } = setup(true);
await waitFor(() => expect(result.current?.[EModelEndpoint.assistants]?.asst_1).toBeDefined());
const firstInner = result.current[EModelEndpoint.assistants];
rerender();
expect(result.current[EModelEndpoint.assistants]).toBe(firstInner);
});
it('is stable while unauthenticated (empty maps do not churn identity)', () => {
const { result, rerender } = setup(false);
const first = result.current;
rerender();
expect(result.current).toBe(first);
});
});

View file

@ -1,18 +1,26 @@
import { useMemo } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { TAssistantsMap } from 'librechat-data-provider';
import { useListAssistantsQuery } from '~/data-provider';
import { mapAssistants } from '~/utils';
/**
* Root feeds this straight into `AssistantsMapContext.Provider`, so the
* returned reference must only change when the underlying data does. The
* previous bare object literal (and `= {}` defaults) minted a new value every
* render, which re-rendered every context consumer — message rows included —
* on any unrelated Root render, e.g. the mobile drawer toggle.
*/
export default function useAssistantsMap({
isAuthenticated,
}: {
isAuthenticated: boolean;
}): TAssistantsMap | undefined {
const { data: assistants = {} } = useListAssistantsQuery(EModelEndpoint.assistants, undefined, {
}): TAssistantsMap {
const { data: assistants } = useListAssistantsQuery(EModelEndpoint.assistants, undefined, {
select: (res) => mapAssistants(res.data),
enabled: isAuthenticated,
});
const { data: azureAssistants = {} } = useListAssistantsQuery(
const { data: azureAssistants } = useListAssistantsQuery(
EModelEndpoint.azureAssistants,
undefined,
{
@ -21,8 +29,11 @@ export default function useAssistantsMap({
},
);
return {
[EModelEndpoint.assistants]: assistants,
[EModelEndpoint.azureAssistants]: azureAssistants,
};
return useMemo(
() => ({
[EModelEndpoint.assistants]: assistants ?? {},
[EModelEndpoint.azureAssistants]: azureAssistants ?? {},
}),
[assistants, azureAssistants],
);
}

View file

@ -1,5 +1,9 @@
import { renderHook } from '@testing-library/react';
import useDrawerSwipe, { findHorizontalScrollBlocker } from '../useDrawerSwipe';
import useDrawerSwipe, {
findHorizontalScrollBlocker,
getPendingDrawerFlip,
kickDrawerAnimation,
} from '../useDrawerSwipe';
import { MOBILE_DRAWER_ID } from '~/components/UnifiedSidebar/constants';
const DRAWER_WIDTH = 375;
@ -432,6 +436,251 @@ describe('useDrawerSwipe — interruptions and re-arming', () => {
});
});
describe('useDrawerSwipe — kickDrawerAnimation (button toggles)', () => {
it('slides both elements before the state flip runs, then hands off to classes', () => {
jest.useFakeTimers();
const harness = setup(false);
/** The sync rAF mock collapses the two-frame deferral, so capturing the
* transform inside `applyState` proves the slide started first. */
const transformAtApply: string[] = [];
kickDrawerAnimation(true, () => {
transformAtApply.push(harness.drawer.style.transform);
});
expect(transformAtApply).toEqual(['translate3d(0, 0, 0)']);
expect(harness.pane.style.transform).toBe('translateX(100%)');
expect(harness.drawer.style.willChange).toBe('transform');
expect(harness.onOpenChange).not.toHaveBeenCalled();
harness.rerender({ open: true, enabled: true });
expect(harness.drawer.style.transform).toBe('translate3d(0, 0, 0)');
jest.runAllTimers();
expect(harness.drawer.style.transform).toBe('');
expect(harness.drawer.style.willChange).toBe('');
expect(harness.pane.style.transform).toBe('translateX(100%)');
jest.useRealTimers();
harness.unmount();
});
it('applies state immediately when the hook is disabled (desktop width animation)', () => {
const harness = setup(false);
harness.rerender({ open: false, enabled: false });
const applyState = jest.fn();
kickDrawerAnimation(true, applyState);
expect(applyState).toHaveBeenCalledTimes(1);
expect(harness.drawer.style.transform).toBe('');
harness.unmount();
});
it('applies state immediately after unmount', () => {
const harness = setup(false);
harness.unmount();
const applyState = jest.fn();
kickDrawerAnimation(true, applyState);
expect(applyState).toHaveBeenCalledTimes(1);
});
it('retargets an in-flight slide when the user toggles back', () => {
jest.useFakeTimers();
const harness = setup(false);
kickDrawerAnimation(true, jest.fn());
harness.rerender({ open: true, enabled: true });
kickDrawerAnimation(false, jest.fn());
expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
expect(harness.pane.style.transform).toBe('translate3d(0, 0, 0)');
harness.rerender({ open: false, enabled: true });
jest.runAllTimers();
expect(harness.drawer.style.transform).toBe('');
expect(harness.pane.style.transform).toBe('');
jest.useRealTimers();
harness.unmount();
});
it('does not re-start a slide already heading to the same target', () => {
jest.useFakeTimers();
const harness = setup(false);
kickDrawerAnimation(true, jest.fn());
harness.drawer.style.transform = 'translate3d(-10px, 0, 0)';
kickDrawerAnimation(true, jest.fn());
expect(harness.drawer.style.transform).toBe('translate3d(-10px, 0, 0)');
jest.useRealTimers();
harness.unmount();
});
it('defers to a drag that owns the inline styles but still applies state', () => {
const harness = setup(false);
harness.swipe(
harness.pane,
[
{ x: 20, y: 100, t: 0 },
{ x: 120, y: 104, t: 50 },
],
false,
);
const applyState = jest.fn();
kickDrawerAnimation(true, applyState);
expect(harness.pane.style.transform).toBe('translate3d(100px, 0, 0)');
expect(applyState).toHaveBeenCalledTimes(1);
harness.pane.dispatchEvent(touchEvent('touchend', [], 400));
harness.unmount();
});
it('snaps without a transition under reduced motion', () => {
jest.useFakeTimers();
const harness = setup(false, true);
kickDrawerAnimation(true, jest.fn());
expect(harness.drawer.style.transition).toBe('none');
expect(harness.drawer.style.transform).toBe('');
harness.rerender({ open: true, enabled: true });
jest.runAllTimers();
expect(harness.drawer.style.transition).not.toBe('none');
jest.useRealTimers();
harness.unmount();
});
/** Deferring past the 80ms transition-restore window would let the state
* flip animate at full duration on a slow-frame device — for exactly the
* users who opted out of motion. */
it('applies state synchronously under reduced motion, without frame deferral', () => {
const harness = setup(false, true);
(window.requestAnimationFrame as jest.Mock).mockImplementation(() => 0);
const applyState = jest.fn();
kickDrawerAnimation(true, applyState);
expect(applyState).toHaveBeenCalledTimes(1);
harness.unmount();
});
it('drops a deferred state flip when the animator is torn down mid-deferral', () => {
const harness = setup(false);
const queued: FrameRequestCallback[] = [];
(window.requestAnimationFrame as jest.Mock).mockImplementation((callback) => {
queued.push(callback);
return queued.length;
});
const applyState = jest.fn();
kickDrawerAnimation(true, applyState);
harness.rerender({ open: false, enabled: false });
while (queued.length) {
queued.shift()?.(0);
}
expect(applyState).not.toHaveBeenCalled();
expect(harness.drawer.style.transform).toBe('');
harness.unmount();
});
/** The release deadline must run on the same clock as the staged frame:
* armed at kick time, a delayed first frame lets the wall-clock timeout
* clear the settle first, and the frame then aborts the slide. */
it('waits for the staged frame before starting the release deadline', () => {
jest.useFakeTimers();
const harness = setup(false);
const queued: FrameRequestCallback[] = [];
(window.requestAnimationFrame as jest.Mock).mockImplementation((callback) => {
queued.push(callback);
return queued.length;
});
kickDrawerAnimation(true, jest.fn());
jest.advanceTimersByTime(500);
queued.shift()?.(0);
expect(harness.drawer.style.transform).toBe('translate3d(0, 0, 0)');
expect(harness.pane.style.transform).toBe('translateX(100%)');
harness.rerender({ open: true, enabled: true });
jest.advanceTimersByTime(400);
expect(harness.drawer.style.transform).toBe('');
expect(harness.pane.style.transform).toBe('translateX(100%)');
jest.useRealTimers();
harness.unmount();
});
/** A superseded flip must never transiently commit the old direction —
* its commit would re-register the animator and get the NEWER flip
* dropped, stranding the drawer at the first intent. */
it('discards a superseded deferred flip so only the latest intent commits', () => {
jest.useFakeTimers();
const harness = setup(false);
const queued: FrameRequestCallback[] = [];
(window.requestAnimationFrame as jest.Mock).mockImplementation((callback) => {
queued.push(callback);
return queued.length;
});
const applyOpen = jest.fn();
const applyClose = jest.fn();
kickDrawerAnimation(true, applyOpen);
kickDrawerAnimation(false, applyClose);
while (queued.length) {
queued.shift()?.(0);
}
expect(applyOpen).not.toHaveBeenCalled();
expect(applyClose).toHaveBeenCalledTimes(1);
jest.runAllTimers();
jest.useRealTimers();
harness.unmount();
});
/** A desktop toggle right after a breakpoint cross must invert the
* committed value, not a leftover mobile intent. */
it('clears the pending flip target when the animator tears down', () => {
const harness = setup(false);
const queued: FrameRequestCallback[] = [];
(window.requestAnimationFrame as jest.Mock).mockImplementation((callback) => {
queued.push(callback);
return queued.length;
});
kickDrawerAnimation(true, jest.fn());
expect(getPendingDrawerFlip()).toBe(true);
harness.rerender({ open: false, enabled: false });
expect(getPendingDrawerFlip()).toBeNull();
harness.unmount();
});
it('applies the deferred state flip when the animator is still live', () => {
jest.useFakeTimers();
const harness = setup(false);
const queued: FrameRequestCallback[] = [];
(window.requestAnimationFrame as jest.Mock).mockImplementation((callback) => {
queued.push(callback);
return queued.length;
});
const applyState = jest.fn();
kickDrawerAnimation(true, applyState);
while (queued.length) {
queued.shift()?.(0);
}
expect(applyState).toHaveBeenCalledTimes(1);
jest.runAllTimers();
jest.useRealTimers();
harness.unmount();
});
});
describe('findHorizontalScrollBlocker', () => {
const scroller = (scrollLeft: number) => {
const el = document.createElement('div');

View file

@ -0,0 +1,177 @@
import React from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { renderHook, act } from '@testing-library/react';
import { MOBILE_DRAWER_ID } from '~/components/UnifiedSidebar/constants';
import useSidebarToggle from '../useSidebarToggle';
import useDrawerSwipe from '../useDrawerSwipe';
import store from '~/store';
/**
* Exercises the real seam: useSidebarToggle must start the drawer slide
* through the swipe hook's animator BEFORE the Recoil flip commits, defer
* follow-up work past the slide, and fall back to immediate application when
* the animator is not active (desktop).
*/
const setup = (enabled: boolean, rafMode: 'sync' | 'captured' = 'sync') => {
const pane = document.createElement('div');
const drawer = document.createElement('div');
drawer.id = MOBILE_DRAWER_ID;
Object.defineProperty(drawer, 'clientWidth', { value: 375, configurable: true });
document.body.append(pane, drawer);
jest.spyOn(window, 'matchMedia').mockReturnValue({
matches: false,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
} as unknown as MediaQueryList);
const queued: FrameRequestCallback[] = [];
jest
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback: FrameRequestCallback) => {
if (rafMode === 'sync') {
callback(0);
return 0;
}
queued.push(callback);
return queued.length;
});
const flushFrames = () => {
while (queued.length) {
queued.shift()?.(0);
}
};
const paneRef = { current: pane };
/** The atom defaults to true (desktop); every scenario here starts from a
* CLOSED mobile drawer, so seed it closed. */
const wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot initializeState={({ set }) => set(store.sidebarExpanded, false)}>
{children}
</RecoilRoot>
);
const rendered = renderHook(
() => {
useDrawerSwipe({ paneRef, enabled, open: false, onOpenChange: jest.fn() });
return {
toggle: useSidebarToggle(),
expanded: useRecoilValue(store.sidebarExpanded),
};
},
{ wrapper },
);
return {
...rendered,
drawer,
flushFrames,
cleanup: () => {
rendered.unmount();
pane.remove();
drawer.remove();
},
};
};
describe('useSidebarToggle', () => {
afterEach(() => {
jest.restoreAllMocks();
document.getElementById(MOBILE_DRAWER_ID)?.remove();
/** Committed flips persist through the atom's localStorage effect, which
* overrides the next test's `initializeState` seed. */
localStorage.clear();
});
it('starts the drawer slide and reports the slide mode (mobile)', () => {
jest.useFakeTimers();
const harness = setup(true);
let mode = '';
act(() => {
mode = harness.result.current.toggle.setSidebarOpen(true);
});
expect(mode).toBe('slide');
expect(harness.drawer.style.transform).toBe('translate3d(0, 0, 0)');
expect(harness.result.current.expanded).toBe(true);
jest.useRealTimers();
harness.cleanup();
});
it('flips state immediately without animating when the hook is disabled (desktop)', () => {
const harness = setup(false);
let mode = '';
act(() => {
mode = harness.result.current.toggle.setSidebarOpen(true);
});
expect(mode).toBe('none');
expect(harness.drawer.style.transform).toBe('');
expect(harness.result.current.expanded).toBe(true);
harness.cleanup();
});
it('defers afterSlide past the slide frames on mobile', () => {
const harness = setup(true, 'captured');
const afterSlide = jest.fn();
act(() => {
harness.result.current.toggle.setSidebarOpen(true, afterSlide);
});
expect(afterSlide).not.toHaveBeenCalled();
act(() => {
harness.flushFrames();
});
expect(afterSlide).toHaveBeenCalledTimes(1);
harness.cleanup();
});
it('runs afterSlide immediately on desktop', () => {
const harness = setup(false);
const afterSlide = jest.fn();
act(() => {
harness.result.current.toggle.setSidebarOpen(true, afterSlide);
});
expect(afterSlide).toHaveBeenCalledTimes(1);
harness.cleanup();
});
/** The user action carried by afterSlide (navigation, focus) must survive
* an animator teardown mid-deferral — only the sidebar flip goes stale. */
it('still runs afterSlide when the animator is torn down mid-deferral', () => {
const harness = setup(true, 'captured');
const afterSlide = jest.fn();
act(() => {
harness.result.current.toggle.setSidebarOpen(true, afterSlide);
});
harness.rerender();
harness.cleanup();
act(() => {
harness.flushFrames();
});
expect(afterSlide).toHaveBeenCalledTimes(1);
});
/** Two presses inside the deferral window must alternate — the second
* inverts the pending target, not the still-uncommitted atom value. */
it('alternates rapid toggles instead of piling onto the same direction', () => {
const harness = setup(true, 'captured');
act(() => {
harness.result.current.toggle.toggleSidebar();
harness.result.current.toggle.toggleSidebar();
});
act(() => {
harness.flushFrames();
});
expect(harness.result.current.expanded).toBe(false);
expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
harness.cleanup();
});
});

View file

@ -23,6 +23,82 @@ const SETTLE_BUFFER_MS = 80;
/** Surfaces where a horizontal drag means selection or caret work, never navigation. */
const TEXT_SURFACE_SELECTOR = 'textarea, input, select, [contenteditable="true"]';
let drawerAnimator: ((next: boolean) => void) | null = null;
/** How a kicked toggle was handled: no animator (desktop/logged out — state
* applied immediately), reduced-motion snap (applied immediately, no slide
* frames), or a deferred slide. */
export type DrawerKickMode = 'none' | 'snap' | 'slide';
/** Target of a slide whose state flip is still deferred, or null when no
* flip is pending. Lets rapid toggles invert the LATEST intent instead of
* the stale committed atom value. */
let pendingFlipTarget: boolean | null = null;
export function getPendingDrawerFlip(): boolean | null {
return pendingFlipTarget;
}
/**
* Starts the drawer/pane slide imperatively and owns WHEN the caller's state
* flip runs. Wrapping the flip in `startTransition` is not enough: Recoil
* notifies subscribers outside the transition scope, so the commit — which a
* large conversation stretches to hundreds of ms of context re-renders plus
* the pane's `inert` recalc — flushes synchronously inside the tap's task and
* delays the slide's first frame (measured ~250ms on a 60-message thread at
* 4× throttle).
*
* So: start the slide imperatively, then run `applyState` three frames
* later — the animator writes the target transforms in frame 1, frame 2
* paints the first interpolated position, and by frame 3 the transform
* animation is compositor-driven (`willChange` is set in the tap's task), so
* the commit lands mid-slide without stuttering or delaying it. When the
* swipe hook is not active — desktop, logged out — `applyState` runs
* immediately and React animates the layout as before.
*
* Returns how the toggle was handled, so callers can keep affordances off
* the paths where they do not apply — e.g. the desktop focus timer only
* runs on 'none', and follow-up work defers only on 'slide'.
*/
export function kickDrawerAnimation(next: boolean, applyState: () => void): DrawerKickMode {
const animator = drawerAnimator;
if (animator == null) {
applyState();
return 'none';
}
animator(next);
/** Reduced motion snaps — there are no slide frames to protect, and
* deferring would let a slow-frame device outrun the 80ms window that
* restores transitions, turning the snap back into a full slide. The
* urgent commit runs inside this same task, which the restore timer
* cannot preempt. */
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
applyState();
return 'snap';
}
pendingFlipTarget = next;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
/** Applies only while still the LATEST un-superseded intent. A
* retarget hands the state to its own deferred flip — this one
* transiently committing the old direction would re-register the
* animator and get the newer flip judged stale. Teardown and
* re-registration (breakpoint cross, logout, route change) clear
* the pending target in the effect cleanup, so stale flips drop
* out here as well instead of toggling the DESKTOP sidebar or
* persisting drawer state past logout. */
if (pendingFlipTarget !== next) {
return;
}
pendingFlipTarget = null;
applyState();
});
});
});
return 'slide';
}
/**
* Walks from `start` up to `boundary` looking for a horizontal scroller that
* can still consume a pan in `direction` (1 = finger moving right, which
@ -135,8 +211,10 @@ export default function useDrawerSwipe({
onOpenChange,
}: DrawerSwipeOptions) {
const gestureRef = useRef<Gesture | null>(null);
/** `id` is null while a kicked settle waits for its staged frame — the
* release deadline runs on the frame clock, not the wall clock. */
const settleRef = useRef<{
id: ReturnType<typeof setTimeout>;
id: ReturnType<typeof setTimeout> | null;
target: boolean;
drawer: HTMLElement;
pane: HTMLElement;
@ -152,7 +230,9 @@ export default function useDrawerSwipe({
* AGREES with `open` keeps running so its handoff finishes undisturbed. */
const pending = settleRef.current;
if (pending != null && (!enabled || pending.target !== open)) {
clearTimeout(pending.id);
if (pending.id != null) {
clearTimeout(pending.id);
}
settleRef.current = null;
releaseInlineStyles(pending.drawer, pending.pane, enabled && open);
}
@ -187,6 +267,16 @@ export default function useDrawerSwipe({
gestureRef.current = null;
};
/** Arms the inline-style handoff: after the shared transition has played
* out, every transient property returns to what React/classes render. */
const scheduleRelease = (drawerEl: HTMLElement, paneEl: HTMLElement, next: boolean) => {
const id = setTimeout(() => {
settleRef.current = null;
releaseInlineStyles(drawerEl, paneEl, next);
}, TRANSITION_MS + SETTLE_BUFFER_MS);
settleRef.current = { id, target: next, drawer: drawerEl, pane: paneEl };
};
/** Animates both elements to `next`, flips state, then returns ownership
* of every inline property to what React/classes render for that state. */
const settle = (gesture: Gesture, next: boolean) => {
@ -215,18 +305,79 @@ export default function useDrawerSwipe({
if (next !== open) {
onOpenChangeRef.current(next);
}
const id = setTimeout(() => {
settleRef.current = null;
drawerEl.style.transform = '';
drawerEl.style.willChange = '';
drawerEl.style.transition = SIDEBAR_TRANSITION;
paneEl.style.transform = next ? 'translateX(100%)' : 'none';
paneEl.style.willChange = '';
paneEl.style.transition = SIDEBAR_TRANSITION;
}, TRANSITION_MS + SETTLE_BUFFER_MS);
settleRef.current = { id, target: next, drawer: drawerEl, pane: paneEl };
scheduleRelease(drawerEl, paneEl, next);
};
/** Imperative slide for button/keyboard toggles: same transforms and
* release lifecycle as a gesture settle, but the CALLER owns the state
* flip — nothing here calls `onOpenChange`. A drag in progress owns the
* inline styles, so it wins; an in-flight settle already heading to
* `next` needs no second start. */
const animateTo = (next: boolean) => {
/** The committed `open` goes stale while a flip is deferred — a rapid
* second toggle must compare against the in-flight settle's target or
* it early-returns and the visual never retargets (the state would
* still alternate, then snap at release). */
const effectiveTarget = settleRef.current?.target ?? open;
if (next === effectiveTarget || gestureRef.current != null) {
return;
}
const pending = settleRef.current;
if (pending != null) {
if (pending.id != null) {
clearTimeout(pending.id);
}
settleRef.current = null;
}
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
/** Snap: suppress the shared transition through the state flip the
* caller is about to make, then hand it back once committed. */
drawer.style.transition = 'none';
pane.style.transition = 'none';
const id = setTimeout(() => {
settleRef.current = null;
drawer.style.transition = SIDEBAR_TRANSITION;
pane.style.transition = SIDEBAR_TRANSITION;
}, SETTLE_BUFFER_MS);
settleRef.current = { id, target: next, drawer, pane };
return;
}
drawer.style.willChange = 'transform';
pane.style.willChange = 'transform';
/** Armed BEFORE the frame below (id: null — no deadline yet) so the
* staged write can verify its target is still wanted, and so a
* touchstart in between defers to the pending settle. A retarget or
* a reconciliation cancel clears/replaces the settle and thereby
* voids the write. */
settleRef.current = { id: null, target: next, drawer, pane };
/** The transforms are written in a FRESH frame task, not the tap's own
* task: a transition armed inside the click task is not reliably
* created for the offscreen drawer (observed via `transitionrun`
* firing only after the state-flip commit, ~270ms late, even with a
* forced reflow in-task), while the same write from a clean task
* starts interpolating by the following frame. The reflow then pins
* the transition-start recalc to this frame instead of 2–3 later.
* The release deadline starts HERE, with the transition itself — a
* wall-clock deadline armed at kick time can expire before a delayed
* first frame (backgrounded tab, long stall) and abort the slide. */
requestAnimationFrame(() => {
const armed = settleRef.current;
if (armed == null || armed.target !== next || armed.id != null) {
return;
}
drawer.style.transition = SIDEBAR_TRANSITION;
pane.style.transition = SIDEBAR_TRANSITION;
drawer.style.transform = next ? 'translate3d(0, 0, 0)' : 'translate3d(-100%, 0, 0)';
pane.style.transform = next ? 'translateX(100%)' : 'translate3d(0, 0, 0)';
void drawer.getBoundingClientRect();
armed.id = setTimeout(() => {
settleRef.current = null;
releaseInlineStyles(drawer, pane, next);
}, TRANSITION_MS + SETTLE_BUFFER_MS);
});
};
drawerAnimator = animateTo;
const onTouchStart = (event: TouchEvent) => {
if (gestureRef.current != null || settleRef.current != null || event.touches.length !== 1) {
return;
@ -377,6 +528,12 @@ export default function useDrawerSwipe({
surface.addEventListener('touchend', onTouchEnd, { passive: true });
surface.addEventListener('touchcancel', onTouchCancel, { passive: true });
return () => {
drawerAnimator = null;
/** A pending intent from the torn-down world must not leak into the
* next one — a desktop toggle right after a breakpoint cross has to
* invert the committed value, and the deferred flip that would have
* consumed this target now drops out of its superseded check. */
pendingFlipTarget = null;
surface.removeEventListener('touchstart', onTouchStart);
surface.removeEventListener('touchmove', onTouchMove);
surface.removeEventListener('touchend', onTouchEnd);
@ -395,7 +552,7 @@ export default function useDrawerSwipe({
useEffect(
() => () => {
if (settleRef.current != null) {
if (settleRef.current?.id != null) {
clearTimeout(settleRef.current.id);
}
},

View file

@ -0,0 +1,76 @@
import { startTransition, useCallback, useMemo } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import type { DrawerKickMode } from './useDrawerSwipe';
import { getPendingDrawerFlip, kickDrawerAnimation } from './useDrawerSwipe';
import store from '~/store';
export type SidebarToggle = {
/**
* Opens/closes the sidebar with the slide-first contract. `afterSlide`
* carries follow-up work — conversation navigation, focus handoffs — that
* would otherwise run in the tap's task and flush its commit before the
* slide's first frame. On 'slide' it runs on the same three-frame cadence
* as the state flip; otherwise immediately.
*/
setSidebarOpen: (next: boolean, afterSlide?: () => void) => DrawerKickMode;
/**
* Inverts the LATEST intent: a second press inside the deferral window
* reads the pending flip target, not the still-uncommitted atom value, so
* rapid presses alternate instead of piling onto the same direction.
*/
toggleSidebar: () => void;
};
/**
* The one path every sidebar open/close mutation takes. On mobile the drawer
* slide starts imperatively (see kickDrawerAnimation) and the Recoil flip is
* deferred past the first frames, so the commit — hundreds of ms on a large
* conversation — cannot delay first motion. Setting the atom directly
* reintroduces exactly that stall, which is why conversation selection and
* the keyboard shortcuts route through here too, not just the toggle
* buttons. On desktop the flip applies immediately and the width transition
* animates as before.
*/
export default function useSidebarToggle(): SidebarToggle {
const setSidebarExpanded = useSetRecoilState(store.sidebarExpanded);
const setSidebarOpen = useCallback(
(next: boolean, afterSlide?: () => void) => {
const mode = kickDrawerAnimation(next, () => {
startTransition(() => {
setSidebarExpanded(next);
});
});
if (afterSlide != null) {
if (mode === 'slide') {
/** Same cadence as the deferred flip, but deliberately NOT gated
* on the animator surviving: the user action carried here (a
* navigation, a focus request) must not vanish because the
* breakpoint crossed mid-slide. */
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(afterSlide);
});
});
} else {
afterSlide();
}
}
return mode;
},
[setSidebarExpanded],
);
const toggleSidebar = useRecoilCallback(
({ snapshot }) =>
() => {
const committed = snapshot.getLoadable(store.sidebarExpanded).valueMaybe() as
| boolean
| undefined;
setSidebarOpen(!(getPendingDrawerFlip() ?? committed ?? false));
},
[setSidebarOpen],
);
return useMemo(() => ({ setSidebarOpen, toggleSidebar }), [setSidebarOpen, toggleSidebar]);
}

View file

@ -15,6 +15,7 @@ import {
parseBinding,
} from '~/utils/shortcuts';
import { mainTextareaId, NotificationSeverity } from '~/common';
import useSidebarToggle from '~/hooks/Nav/useSidebarToggle';
import { useArchiveConvoMutation } from '~/data-provider';
import { useHasAccess, useLocalize } from '~/hooks';
import useNewChat from '~/hooks/Chat/useNewChat';
@ -500,7 +501,8 @@ export function useShortcutActions(): ShortcutAction[] {
const routeConvoId = routeMatch?.params.conversationId ?? null;
const conversation = useRecoilValue(store.conversationByIndex(0));
const isSubmitting = useRecoilValue(store.isSubmittingFamily(0));
const [sidebarExpanded, setSidebarExpanded] = useRecoilState(store.sidebarExpanded);
const sidebarExpanded = useRecoilValue(store.sidebarExpanded);
const { setSidebarOpen, toggleSidebar } = useSidebarToggle();
const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog);
const setIsTemporary = useSetRecoilState(store.isTemporary);
const setDeleteTarget = useSetRecoilState(store.keyboardDeleteTarget);
@ -531,9 +533,9 @@ export function useShortcutActions(): ShortcutAction[] {
}, []);
const handleToggleSidebar = useCallback(() => {
setSidebarExpanded((prev) => !prev);
toggleSidebar();
return true;
}, [setSidebarExpanded]);
}, [toggleSidebar]);
const handleOpenModelSelector = useCallback(
() => clickElement('[data-testid="model-selector-button"]'),
@ -566,16 +568,23 @@ export function useShortcutActions(): ShortcutAction[] {
}
if (!sidebarExpanded) {
setSidebarExpanded(true);
/** The focus rides `afterSlide` + a zero timer: it must queue behind
* the deferred flip's commit (which un-inerts the drawer), where a
* fixed 350ms guess could fire into the still-inert drawer and be
* silently ignored. */
setSidebarOpen(true, () => {
setTimeout(focusSearchInput, 0);
});
return true;
}
if (!sidebarExpanded || switchedPanel) {
if (switchedPanel) {
setTimeout(focusSearchInput, 350);
return true;
}
return focusSearchInput();
}, [sidebarExpanded, setSidebarExpanded]);
}, [sidebarExpanded, setSidebarOpen]);
const handleCopyLastResponse = useCallback(() => {
return clickLastElement('[data-testid="copy-response-button"]');
@ -796,14 +805,18 @@ export function useShortcutActions(): ShortcutAction[] {
}
if (!sidebarExpanded) {
setSidebarExpanded(true);
setTimeout(activatePanel, 350);
/** Queued behind the deferred flip's commit, like the search focus
* above — the panel button is unreachable while the drawer is
* inert. */
setSidebarOpen(true, () => {
setTimeout(activatePanel, 0);
});
return true;
}
return activatePanel();
},
[sidebarExpanded, setSidebarExpanded],
[sidebarExpanded, setSidebarOpen],
);
const handleOpenAssistants = useCallback(() => handleOpenPanel('assistants'), [handleOpenPanel]);

View file

@ -18,7 +18,6 @@ import { UnifiedSidebar, SIDEBAR_TRANSITION } from '~/components/UnifiedSidebar'
import KeyboardShortcutsDialog from '~/components/Nav/KeyboardShortcutsDialog';
import KeyboardDeleteDialog from '~/components/Nav/KeyboardDeleteDialog';
import { useUserTermsQuery, useGetStartupConfig } from '~/data-provider';
import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar';
import useKeyboardShortcuts from '~/hooks/useKeyboardShortcuts';
import useSidebarState from '~/hooks/Nav/useSidebarState';
import { TermsAndConditionsModal } from '~/components/ui';
@ -47,18 +46,14 @@ export default function Root() {
setExpanded: setSidebarExpanded,
} = useSidebarState();
const paneRef = useRef<HTMLDivElement>(null);
/** Focus handoff lives in the drawer header's own expanded-effect — the
* commit drives it, so every opener (button, swipe) is covered without a
* timer racing the deferred state flip. */
const handleDrawerOpenChange = useCallback(
(next: boolean) => {
startTransition(() => {
setSidebarExpanded(next);
});
if (next) {
/** Same handoff as the OpenSidebar button: opening makes the pane
* inert, so keyboard/AT focus must land inside the drawer. */
setTimeout(() => {
document.getElementById(CLOSE_SIDEBAR_ID)?.focus();
}, 250);
}
},
[setSidebarExpanded],
);

View file

@ -18,6 +18,18 @@ describe('Button', () => {
);
});
it('renders the header-action toggle from semantic tokens', () => {
render(<Button variant="header-action">Toggle</Button>);
expect(screen.getByRole('button', { name: 'Toggle' })).toHaveClass(
'bg-presentation',
'border-border-light',
'rounded-xl',
'duration-0',
'hover:bg-surface-active-alt',
);
});
it('preserves variant geometry until a shape is explicitly selected', () => {
const { rerender } = render(<Button variant="subtle">Subtle</Button>);
const button = screen.getByRole('button', { name: 'Subtle' });

View file

@ -17,6 +17,7 @@ type ButtonVariantOptions =
| 'ghost'
| 'row-action'
| 'section-action'
| 'header-action'
| null
| undefined;
size?: 'default' | 'icon' | 'icon-sm' | 'icon-xs' | 'sm' | 'lg' | 'theme' | null | undefined;
@ -49,6 +50,16 @@ const buttonVariantRecipe = cva(
*/
'section-action':
'rounded-md text-text-secondary hover:bg-surface-active-alt hover:text-text-primary focus-visible:ring-inset focus-visible:ring-offset-0',
/**
* A control floating on the presentation surface — the sidebar
* toggle in the chat header and its mirror in the mobile drawer
* header, so the pair reads as one persistent button across views.
* `duration-0` makes the hover fill instant: these sit over a
* scrolling gradient, where the shared color transition reads as
* lag rather than polish.
*/
'header-action':
'rounded-xl border border-border-light bg-presentation text-text-primary duration-0 hover:bg-surface-active-alt hover:text-text-primary',
},
size: {
default: 'h-10 px-4 py-2',