From 0f376884bdf0acb930f9b0f4e9a726303b6371b5 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:34:39 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=B1=20feat:=20Offer=20a=20Dismissable?= =?UTF-8?q?=20Strip=20of=20Chat=20Beside=20the=20Mobile=20Drawer=20(#14990?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: leave a dismissable strip of chat beside the mobile drawer The drawer took the whole viewport, so opening it read as a screen change rather than a layer over the conversation, and the only ways back were the header button and a swipe. It now stops at 80% and the chat stays visible behind a scrim, which is itself the dismiss target: tapping it closes the drawer and returns to the conversation, which never navigated away. The scrim renders as a sibling of the pane rather than inside it, because the pane is inert while the drawer is open and would swallow the click. Drawer width and pane travel derive from one constant so they cannot drift. Closing had to change with it. A programmatic close repositioned the pane instantly, which was invisible only because a full-width opaque drawer covered the jump; with a strip on screen that jump lands in plain view, so both surfaces animate together, the motion the drag path already produced. The spec that pinned the old reveal is rewritten to pin this. The easing also changed: the previous curve spent its last third of the duration on a few percent of the distance, which read as the panel sticking just before it landed, most obvious on close. Three things the scrim has to respect, each found in review: - It routes through useSidebarToggle rather than writing the atom, so the slide still starts imperatively and a large conversation cannot stall it. - It drops its fade under prefers-reduced-motion, matching the snap kickDrawerAnimation already performs. - It stays the pointer target until the close animation settles, derived from the committed state so every close path is covered, and cleared on a timer so a scrim unmounting at the breakpoint cannot strand it. Focus returns to the drawer's opener once the closed state commits, since the scrim goes aria-hidden and untabbable. * fix: close the mobile drawer predictably from every path Move the close handling out of Root into useDrawerDismiss, which fixes three things the split scrim-owned version got wrong: A breakpoint crossing derives the drawer closed with nothing to animate, so narrowing a window or rotating a tablet armed the pointer guard and left a transparent full-screen scrim swallowing taps for 300ms. The scrim stays the pointer target through that guard, where the state has already committed. A tap there closed again, a no-op that never reached the focus handoff, stranding the restore flag to fire on a later close. Focus was only restored when the scrim itself closed the drawer, and only to the header opener. Closing from the drawer button or Escape left focus in a subtree that goes inert, and routes that render no opener left it on the scrim once it went aria-hidden. Every close path now restores, to the opener or the pane, and only when the close is what dropped focus. * style: sort imports in the new drawer hook * fix: reclaim focus from the scrim when Escape closes the drawer The drawer's Escape handler closes it without going through the scrim, so a keyboard user who tabbed there kept focus on a button that becomes aria-hidden and untabbable. Inert drops focus to the body by itself; aria-hidden does not, so it has to count as lost too. * feat: make the mobile chat strip a setting, off by default The drawer covering the full width and closing by swipe stays the default. Turning the setting on stops it short of the edge, leaving a strip of the conversation visible that also closes the drawer when tapped. Both surfaces read one custom property for how far the drawer opens, so the value can change at runtime without threading a number through the swipe gesture, and their travel still cannot drift apart. The fallback is the default, so anything rendered outside the property's scope agrees too. The scrim moves into its own component, which is what makes its tab order, aria-hidden and pointer-events states testable. * fix: keep the reveal close on the default full-width drawer Making the strip opt-in put the paired close animation on the default path, where the drawer covers the pane: selecting a conversation then visibly shifted the chat leftward while the new one committed into the moving layer, which is the regression the reveal existed to avoid. The reveal is now chosen from geometry rather than the setting, since it is safe exactly when the drawer hides the pane, however the width was arrived at. The drawer also transitions its width, so toggling the setting while it is open moves both surfaces on one curve instead of jumping the width in a frame while the pane eases across the transition. * fix: honour reduced motion when the strip setting changes the width Changing the setting updates the width custom property directly rather than going through the snap path, so the drawer eased its width and the pane its transform for the full transition even for a user who asked for no motion. The preference now reaches the declarative styles on both surfaces, and the snap no longer hands an animating transition back afterwards, which is what left the element ready to ease the next change. * fix: cover the gesture snap, the close frame and the breakpoint focus The gesture settle restored the transitions directly rather than through the reduced-motion handoff, so a swipe left both surfaces ready to animate the next width change. The close guard was armed from a passive effect, which runs after paint, leaving one frame where the pane had dropped inert and the scrim had not yet taken the pointer back. It is armed in the committing frame now. Crossing into mobile with focus inside the expanded desktop sidebar drops it when that subtree unmounts. The guard is still right to stay disarmed there, since nothing animates, but the focus handoff has to run, so the two no longer share an early return. * fix: keep the pointer guard tied to a pane that actually moves Disabling the strip unmounts the scrim at once while the drawer needs the whole transition to widen, so a close begun in that window still slid the pane with nothing holding the pointer. The scrim now stays mounted while a close is in flight. Arming that guard is tied to the same geometry the close path already branches on. A close under a drawer that covers the pane is a reveal, with the pane already in place, so holding the pointer there would only make the default configuration feel unresponsive for the length of the transition. * fix: guard the swipe close and hand focus back off the mobile breakpoint The guard read the drawer's width to decide whether the pane was moving, but a swipe animates the pane at any width, so the default configuration went unguarded through the one close path that does move it. It now asks the pane itself: the reveal leaves transition none behind, every animated path leaves the shared transition on it before the state commits. Leaving mobile unmounts the drawer and the scrim, so focus sitting on either went to the document. The same handoff runs for that direction, and it now confirms the opener actually took focus rather than assuming: the opener stays mounted across breakpoints but is hidden on desktop. The scrim is imported through the mobile directory's barrel. * fix: address PR review bot findings Codex: - Start the scrim fade with the drawer slide, not the deferred Recoil commit - Keep the scrim focus ring inside the overflow-hidden shell * fix: address PR review bot findings Codex: - Capture pointer events on the scrim as soon as an open slide starts - Expire the close guard at the animation deadline, not a fresh 300ms - Keep pointer capture through a reveal close while the drawer still slides * fix: hand focus back once the close guard releases Codex: - Defer focus restoration until the pane is no longer inert The guard reapplies inert to the pane in the same commit the close lands, and both the opener and the pane itself sit inside it, so the handoff was ejected to the body with no dependency left to re-run it. The release now flushes before focus moves. * fix: drop the scrim pointer override when the close slide starts Codex: - Clear the opening pointer override on close The opening kick writes an inline pointer-events override that only the buffered release cleared, so a dismiss inside that window left the invisible scrim swallowing taps past the guard. The close now hands capture back to the classes, which already hold it for the guard's duration. * fix: carry the focus handoff and the slide's own clocks through a close Codex: - Preserve focus when the motion preference changes mid-close - Keep the scrim armed when an opening is canceled - Stabilize the drawer width before closing mid-toggle The handoff is now keyed off the guard releasing rather than the timer, so a guard cancelled by a dependency change still hands focus back once the pane sheds inert. A close that cancels an uncommitted open never reaches the isClosing classes, so it keeps the scrim's pointer override instead of returning capture that nothing else holds. And the close pins the drawer's measured width, so a width transition still in flight cannot drive its edge from a second clock and open a gap against the pane. * fix: guard a close the committed state never reports Codex: - Guard canceled opens when the strip is disabled - Stabilize the drawer width before an in-flight swipe A second toggle inside the deferred flip, or an open drag that falls short, moves the pane without expanded ever changing, so the guard had no transition to arm from and the default configuration left the pane live as it uncovered. The slide now reports itself and arms the guard directly, which also makes the scrim's pointer override unconditional again: every close hands capture back to the classes. Claiming a gesture drops the transition, which lands a width still easing toward the strip target on that target in the same frame, so the touchstart snapshot went stale and held a gap open between the surfaces for the rest of the drag. The claim remeasures. * fix: cover the opening travel and compose the scrim's button Codex: - Guard the pane during default drawer opens - Compose the shared button primitive for the scrim Recoil's flip is deferred past the opening frames and the closing transition outlives it at the other end, so the committed state brackets the travel too late and drops it too early. The guard is now named for what it measures and arms for any slide the committed state does not report, so the default configuration covers the pane while the drawer travels over it. Only a close records the focus handoff; an open hands focus to the drawer's header. The scrim now composes the shared Button, keeping only the inset ring the overflow-hidden shell requires. --- .../src/components/Nav/Settings/registry.tsx | 13 + .../UnifiedSidebar/UnifiedSidebar.tsx | 16 +- .../components/UnifiedSidebar/constants.ts | 43 +- .../UnifiedSidebar/mobile/Scrim.tsx | 58 ++ .../mobile/__tests__/Scrim.spec.tsx | 90 +++ .../components/UnifiedSidebar/mobile/index.ts | 1 + .../Nav/__tests__/useDrawerDismiss.spec.tsx | 519 ++++++++++++++++++ .../Nav/__tests__/useDrawerSwipe.spec.tsx | 305 +++++++++- client/src/hooks/Nav/useDrawerDismiss.ts | 236 ++++++++ client/src/hooks/Nav/useDrawerSwipe.ts | 144 ++++- client/src/locales/en/translation.json | 4 +- client/src/routes/Root.tsx | 80 ++- client/src/store/settings.ts | 6 + 13 files changed, 1464 insertions(+), 51 deletions(-) create mode 100644 client/src/components/UnifiedSidebar/mobile/Scrim.tsx create mode 100644 client/src/components/UnifiedSidebar/mobile/__tests__/Scrim.spec.tsx create mode 100644 client/src/hooks/Nav/__tests__/useDrawerDismiss.spec.tsx create mode 100644 client/src/hooks/Nav/useDrawerDismiss.ts diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index 0d6ef12b58..081959157d 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -121,6 +121,19 @@ export const registry: SettingEntry[] = [ switchId: 'showScrollButton', }), }, + { + id: 'mobileDrawerStrip', + tab: GENERAL, + section: 'layout', + labelKey: 'com_nav_mobile_drawer_strip', + keywords: ['mobile', 'sidebar', 'drawer', 'swipe'], + Component: toggleControl({ + stateAtom: store.mobileDrawerStrip, + localizationKey: 'com_nav_mobile_drawer_strip', + hoverCardText: 'com_nav_mobile_drawer_strip_info', + switchId: 'mobileDrawerStrip', + }), + }, { id: 'chatTitleInTab', tab: GENERAL, diff --git a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx index dc0647b5e4..7495e14051 100644 --- a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx +++ b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx @@ -1,5 +1,6 @@ import { useCallback, useState, useEffect, useRef, memo } from 'react'; import { useForm } from 'react-hook-form'; +import { useMediaQuery } from '@librechat/client'; import { useLocation, useNavigate } from 'react-router-dom'; import type { ReactNode } from 'react'; import type { ChatFormValues } from '~/common'; @@ -8,9 +9,10 @@ import { EXPANDED_MIN, TRANSITION_MS, EASING, - SIDEBAR_TRANSITION, + MOBILE_DRAWER_TRANSITION, DRAWER_Z_INDEX, MOBILE_DRAWER_ID, + MOBILE_DRAWER_WIDTH, } from './constants'; import { ChatContext, ChatFormProvider, ActivePanelProvider } from '~/Providers'; import { MobileHeader, MobileBottomBar, MobileShortcutTargets } from './mobile'; @@ -49,6 +51,7 @@ function UnifiedSidebar() { const navigate = useNavigate(); const { isSmallScreen, expanded } = useSidebarState(); const { setSidebarOpen } = useSidebarToggle(); + const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); const [sidebarWidth, setSidebarWidth] = useState(getInitialWidth); const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth); const [isResizing, setIsResizing] = useState(false); @@ -188,10 +191,17 @@ function UnifiedSidebar() { /** 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. */ - 'fixed inset-y-0 left-0 flex w-full touch-pan-y touch-pinch-zoom flex-col bg-surface-primary-alt', + 'fixed inset-y-0 left-0 flex touch-pan-y touch-pinch-zoom flex-col bg-surface-primary-alt', expanded ? 'translate-x-0' : '-translate-x-full', )} - style={{ transition: SIDEBAR_TRANSITION, zIndex: DRAWER_Z_INDEX }} + style={{ + width: MOBILE_DRAWER_WIDTH, + /** The strip setting changes the width without passing through the + * snap path, so the preference has to reach the declarative style + * too or that one change still animates. */ + transition: prefersReducedMotion ? undefined : MOBILE_DRAWER_TRANSITION, + zIndex: DRAWER_Z_INDEX, + }} inert={!expanded ? '' : undefined} > diff --git a/client/src/components/UnifiedSidebar/constants.ts b/client/src/components/UnifiedSidebar/constants.ts index ba26af57bc..8953123590 100644 --- a/client/src/components/UnifiedSidebar/constants.ts +++ b/client/src/components/UnifiedSidebar/constants.ts @@ -1,7 +1,32 @@ export const COLLAPSED_WIDTH = 52; export const EXPANDED_MIN = 360; + +/** + * How much of the viewport the mobile drawer covers. + * + * Full width by default, where the drawer reads as its own screen and the + * swipe closes it. Opting into the strip stops it short of the edge so a slice + * of the conversation stays visible, which keeps the drawer reading as a layer + * over the conversation and gives the close gesture a target to tap. + * + * Both the drawer and the pane read the one custom property, so their travel + * cannot drift apart (see SIDEBAR_TRANSITION) and the setting can change at + * runtime without threading a number through either. The fallback is the + * default, so anything rendered outside the property's scope still agrees. + */ +export const MOBILE_DRAWER_WIDTH_VAR = '--mobile-drawer-width'; +export const MOBILE_DRAWER_FULL_WIDTH = '100%'; +export const MOBILE_DRAWER_STRIP_WIDTH = '80%'; +export const MOBILE_DRAWER_WIDTH = `var(${MOBILE_DRAWER_WIDTH_VAR}, ${MOBILE_DRAWER_FULL_WIDTH})`; +export const MOBILE_PANE_SHIFT = `translateX(${MOBILE_DRAWER_WIDTH})`; export const TRANSITION_MS = 300; -export const EASING = 'cubic-bezier(0.2, 0, 0, 1)'; +/** + * Decelerating, but it settles rather than crawls. The previous + * cubic-bezier(0.2, 0, 0, 1) spent its last third of time on a few percent of + * distance, which reads as the drawer sticking just before it lands — most + * obvious on close, where the tail is the part you watch. + */ +export const EASING = 'cubic-bezier(0.32, 0.72, 0, 1)'; /** * The drawer and the chat pane move as one object, so they must stay @@ -9,6 +34,16 @@ export const EASING = 'cubic-bezier(0.2, 0, 0, 1)'; */ export const SIDEBAR_TRANSITION = `transform ${TRANSITION_MS}ms ${EASING}`; +/** + * The drawer also transitions its width, because that is the one property the + * pane tracks through its own transform. Changing the strip setting while the + * drawer is open would otherwise jump the width in a frame while the pane + * eased across 300ms, leaving the newly exposed slice with no conversation + * under it. Only the drawer needs this: the pane's width is flex-driven and + * animating it would reach the desktop sidebar's collapse as well. + */ +export const MOBILE_DRAWER_TRANSITION = `${SIDEBAR_TRANSITION}, width ${TRANSITION_MS}ms ${EASING}`; + /** * The mobile drawer is opaque and full-screen, so it sits above the chat. * @@ -26,3 +61,9 @@ export const DRAWER_Z_INDEX = 110; * the drawer element without threading a ref across sibling trees. */ export const MOBILE_DRAWER_ID = 'mobile-drawer'; + +/** + * Lets a kicked toggle start the scrim fade with the drawer, rather than + * waiting for the deferred Recoil commit that a large conversation stalls. + */ +export const MOBILE_SCRIM_ID = 'mobile-drawer-scrim'; diff --git a/client/src/components/UnifiedSidebar/mobile/Scrim.tsx b/client/src/components/UnifiedSidebar/mobile/Scrim.tsx new file mode 100644 index 0000000000..a11167cfed --- /dev/null +++ b/client/src/components/UnifiedSidebar/mobile/Scrim.tsx @@ -0,0 +1,58 @@ +import { Button } from '@librechat/client'; +import type { MouseEvent } from 'react'; +import { DRAWER_Z_INDEX, MOBILE_SCRIM_ID, TRANSITION_MS, EASING } from '../constants'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +/** + * Covers the strip of conversation the drawer leaves visible, and dismisses it + * when tapped. Rendered as a sibling of the chat pane rather than inside it, + * because the pane is inert while the drawer is open and would swallow the + * click. + * + * The shared button carries the focus treatment; `variant`/`size` are cleared + * because a full-bleed surface wants none of the chrome, not a different set + * of it. + */ +export default function Scrim({ + expanded, + isSliding, + prefersReducedMotion, + onClick, +}: { + expanded: boolean; + /** Both surfaces keep travelling outside the committed state, and a tap in + * that window would otherwise reach a control on the pane sliding past. */ + isSliding: boolean; + prefersReducedMotion: boolean; + onClick: (event: MouseEvent) => void; +}) { + const localize = useLocalize(); + + return ( +