diff --git a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx
index a264ce3602..fcee6e34c1 100644
--- a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx
+++ b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx
@@ -9,6 +9,7 @@ import {
EASING,
SIDEBAR_TRANSITION,
DRAWER_Z_INDEX,
+ MOBILE_DRAWER_ID,
} from './constants';
import { ChatContext, ChatFormProvider, ActivePanelProvider } from '~/Providers';
import { MobileHeader, MobileBottomBar, MobileShortcutTargets } from './mobile';
@@ -148,8 +149,12 @@ function UnifiedSidebar() {
if (isSmallScreen) {
return (
+ ({ clientX: x, clientY: y, identifier }) as Touch;
+
+const touchEvent = (
+ type: string,
+ touches: Touch[],
+ timeStamp: number,
+ changedTouches?: Touch[],
+): TouchEvent => {
+ const event = new Event(type, { bubbles: true, cancelable: true }) as TouchEvent;
+ Object.defineProperty(event, 'touches', { value: touches });
+ Object.defineProperty(event, 'changedTouches', {
+ value: changedTouches ?? [createTouch(0, 0, 0)],
+ });
+ Object.defineProperty(event, 'timeStamp', { value: timeStamp });
+ return event;
+};
+
+type Harness = {
+ pane: HTMLDivElement;
+ drawer: HTMLDivElement;
+ onOpenChange: jest.Mock;
+ swipe: (
+ surface: HTMLElement,
+ points: Array<{ x: number; y: number; t: number; target?: Element }>,
+ end?: boolean,
+ ) => TouchEvent[];
+ rerender: (props: { open: boolean; enabled: boolean }) => void;
+ unmount: () => void;
+};
+
+const setup = (open: boolean, reducedMotion = false): Harness => {
+ const pane = document.createElement('div');
+ const drawer = document.createElement('div');
+ drawer.id = MOBILE_DRAWER_ID;
+ Object.defineProperty(drawer, 'clientWidth', { value: DRAWER_WIDTH, configurable: true });
+ document.body.append(pane, drawer);
+
+ jest.spyOn(window, 'matchMedia').mockReturnValue({
+ matches: reducedMotion,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ } as unknown as MediaQueryList);
+ jest
+ .spyOn(window, 'requestAnimationFrame')
+ .mockImplementation((callback: FrameRequestCallback) => {
+ callback(0);
+ return 0;
+ });
+
+ const onOpenChange = jest.fn();
+ const paneRef = { current: pane };
+ const { unmount, rerender } = renderHook(
+ (props: { open: boolean; enabled: boolean }) =>
+ useDrawerSwipe({ paneRef, enabled: props.enabled, open: props.open, onOpenChange }),
+ { initialProps: { open, enabled: true } },
+ );
+
+ const swipe: Harness['swipe'] = (surface, points, end = true) => {
+ const events: TouchEvent[] = [];
+ points.forEach(({ x, y, t, target }, index) => {
+ const event = touchEvent(index === 0 ? 'touchstart' : 'touchmove', [createTouch(x, y)], t);
+ (target ?? surface).dispatchEvent(event);
+ events.push(event);
+ });
+ if (end) {
+ const last = points[points.length - 1];
+ surface.dispatchEvent(touchEvent('touchend', [], last.t + 16));
+ }
+ return events;
+ };
+
+ return {
+ pane,
+ drawer,
+ onOpenChange,
+ swipe,
+ rerender,
+ unmount: () => {
+ unmount();
+ pane.remove();
+ drawer.remove();
+ },
+ };
+};
+
+afterEach(() => {
+ document.getElementById(MOBILE_DRAWER_ID)?.remove();
+});
+
+describe('useDrawerSwipe — opening from the chat pane', () => {
+ it('follows the finger and commits past the distance threshold', () => {
+ const harness = setup(false);
+ const events = harness.swipe(
+ harness.pane,
+ [
+ { x: 20, y: 100, t: 0 },
+ { x: 120, y: 104, t: 50 },
+ { x: 220, y: 108, t: 100 },
+ ],
+ false,
+ );
+
+ expect(events[1].defaultPrevented).toBe(true);
+ expect(harness.drawer.style.transform).toBe(`translate3d(${200 - DRAWER_WIDTH}px, 0, 0)`);
+ expect(harness.pane.style.transform).toBe('translate3d(200px, 0, 0)');
+ expect(harness.drawer.style.transition).toBe('none');
+
+ harness.pane.dispatchEvent(touchEvent('touchend', [], 120));
+
+ expect(harness.onOpenChange).toHaveBeenCalledWith(true);
+ expect(harness.drawer.style.transform).toBe('translate3d(0, 0, 0)');
+ expect(harness.pane.style.transform).toBe('translateX(100%)');
+ harness.unmount();
+ });
+
+ it('reverts a slow swipe released under the threshold', () => {
+ const harness = setup(false);
+ harness.swipe(harness.pane, [
+ { x: 20, y: 100, t: 0 },
+ { x: 60, y: 100, t: 200 },
+ { x: 80, y: 100, t: 400 },
+ ]);
+
+ expect(harness.onOpenChange).not.toHaveBeenCalled();
+ expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
+ expect(harness.pane.style.transform).toBe('translate3d(0, 0, 0)');
+ harness.unmount();
+ });
+
+ it('commits a fast flick that never reaches the distance threshold', () => {
+ const harness = setup(false);
+ harness.swipe(harness.pane, [
+ { x: 20, y: 100, t: 0 },
+ { x: 50, y: 100, t: 20 },
+ { x: 80, y: 100, t: 40 },
+ ]);
+
+ expect(harness.onOpenChange).toHaveBeenCalledWith(true);
+ harness.unmount();
+ });
+
+ it('cedes the touch to vertical scrolling once the axis locks that way', () => {
+ const harness = setup(false);
+ const events = harness.swipe(harness.pane, [
+ { x: 20, y: 100, t: 0 },
+ { x: 24, y: 140, t: 50 },
+ { x: 90, y: 180, t: 100 },
+ ]);
+
+ expect(events[1].defaultPrevented).toBe(false);
+ expect(events[2].defaultPrevented).toBe(false);
+ expect(harness.drawer.style.transform).toBe('');
+ expect(harness.onOpenChange).not.toHaveBeenCalled();
+ harness.unmount();
+ });
+
+ it('defers to a horizontal scroller that can still pan the same way', () => {
+ const harness = setup(false);
+ const scroller = document.createElement('div');
+ scroller.style.overflowX = 'auto';
+ Object.defineProperty(scroller, 'scrollWidth', { value: 600, configurable: true });
+ Object.defineProperty(scroller, 'clientWidth', { value: 300, configurable: true });
+ scroller.scrollLeft = 120;
+ harness.pane.append(scroller);
+
+ harness.swipe(harness.pane, [
+ { x: 20, y: 100, t: 0, target: scroller },
+ { x: 220, y: 100, t: 50, target: scroller },
+ { x: 260, y: 100, t: 100, target: scroller },
+ ]);
+
+ expect(harness.onOpenChange).not.toHaveBeenCalled();
+ expect(harness.drawer.style.transform).toBe('');
+ harness.unmount();
+ });
+
+ it('claims the drag when that scroller is already parked at its edge', () => {
+ const harness = setup(false);
+ const scroller = document.createElement('div');
+ scroller.style.overflowX = 'auto';
+ Object.defineProperty(scroller, 'scrollWidth', { value: 600, configurable: true });
+ Object.defineProperty(scroller, 'clientWidth', { value: 300, configurable: true });
+ scroller.scrollLeft = 0;
+ harness.pane.append(scroller);
+
+ harness.swipe(harness.pane, [
+ { x: 20, y: 100, t: 0, target: scroller },
+ { x: 220, y: 100, t: 50, target: scroller },
+ ]);
+
+ expect(harness.onOpenChange).toHaveBeenCalledWith(true);
+ harness.unmount();
+ });
+
+ it('never claims a drag that starts in a text surface', () => {
+ const harness = setup(false);
+ const textarea = document.createElement('textarea');
+ harness.pane.append(textarea);
+
+ harness.swipe(harness.pane, [
+ { x: 20, y: 100, t: 0, target: textarea },
+ { x: 220, y: 100, t: 50, target: textarea },
+ ]);
+
+ expect(harness.onOpenChange).not.toHaveBeenCalled();
+ expect(harness.drawer.style.transform).toBe('');
+ harness.unmount();
+ });
+
+ it('snaps without tracking under prefers-reduced-motion', () => {
+ const harness = setup(false, true);
+ harness.swipe(harness.pane, [
+ { x: 20, y: 100, t: 0 },
+ { x: 240, y: 100, t: 50 },
+ ]);
+
+ expect(harness.drawer.style.transform).toBe('');
+ expect(harness.pane.style.transform).toBe('');
+ expect(harness.onOpenChange).toHaveBeenCalledWith(true);
+ /** The snap must suppress the shared 300ms transition for the flip. */
+ expect(harness.drawer.style.transition).toBe('none');
+ expect(harness.pane.style.transition).toBe('none');
+ harness.unmount();
+ });
+
+ it('reverts cleanly when the browser cancels the touch', () => {
+ const harness = setup(false);
+ harness.swipe(
+ harness.pane,
+ [
+ { x: 20, y: 100, t: 0 },
+ { x: 220, y: 100, t: 50 },
+ ],
+ false,
+ );
+ harness.pane.dispatchEvent(touchEvent('touchcancel', [], 80));
+
+ expect(harness.onOpenChange).not.toHaveBeenCalled();
+ expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
+ harness.unmount();
+ });
+});
+
+describe('useDrawerSwipe — closing from the drawer', () => {
+ it('closes on a committed leftward swipe', () => {
+ const harness = setup(true);
+ harness.swipe(harness.drawer, [
+ { x: 350, y: 100, t: 0 },
+ { x: 250, y: 100, t: 50 },
+ { x: 150, y: 100, t: 100 },
+ ]);
+
+ expect(harness.onOpenChange).toHaveBeenCalledWith(false);
+ expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
+ expect(harness.pane.style.transform).toBe('translate3d(0, 0, 0)');
+ harness.unmount();
+ });
+
+ it('ignores a rightward swipe while already open', () => {
+ const harness = setup(true);
+ harness.swipe(harness.drawer, [
+ { x: 50, y: 100, t: 0 },
+ { x: 250, y: 100, t: 50 },
+ ]);
+
+ expect(harness.onOpenChange).not.toHaveBeenCalled();
+ harness.unmount();
+ });
+});
+
+describe('useDrawerSwipe — interruptions and re-arming', () => {
+ it('cancels a stale settle when a button closes the drawer inside its window', () => {
+ jest.useFakeTimers();
+ const harness = setup(false);
+ harness.swipe(harness.pane, [
+ { x: 20, y: 100, t: 0 },
+ { x: 220, y: 100, t: 50 },
+ ]);
+ expect(harness.onOpenChange).toHaveBeenCalledWith(true);
+ expect(harness.pane.style.transform).toBe('translateX(100%)');
+
+ harness.rerender({ open: true, enabled: true });
+ harness.rerender({ open: false, enabled: true });
+ expect(harness.pane.style.transform).toBe('');
+ expect(harness.drawer.style.transform).toBe('');
+
+ jest.runAllTimers();
+ expect(harness.pane.style.transform).toBe('');
+ jest.useRealTimers();
+ harness.unmount();
+ });
+
+ it('keeps a settle that agrees with the state change it caused', () => {
+ jest.useFakeTimers();
+ const harness = setup(false);
+ harness.swipe(harness.pane, [
+ { x: 20, y: 100, t: 0 },
+ { x: 220, y: 100, t: 50 },
+ ]);
+
+ harness.rerender({ open: true, enabled: true });
+ expect(harness.drawer.style.transform).toBe('translate3d(0, 0, 0)');
+
+ jest.runAllTimers();
+ expect(harness.pane.style.transform).toBe('translateX(100%)');
+ expect(harness.drawer.style.transform).toBe('');
+ jest.useRealTimers();
+ harness.unmount();
+ });
+
+ it('ignores a second finger lifting mid-drag and settles only on full release', () => {
+ const harness = setup(false);
+ harness.swipe(
+ harness.pane,
+ [
+ { x: 20, y: 100, t: 0 },
+ { x: 220, y: 100, t: 50 },
+ ],
+ false,
+ );
+
+ harness.pane.dispatchEvent(
+ touchEvent('touchend', [createTouch(220, 100, 0)], 80, [createTouch(80, 200, 1)]),
+ );
+ expect(harness.onOpenChange).not.toHaveBeenCalled();
+ expect(harness.pane.style.transform).toBe('translate3d(200px, 0, 0)');
+
+ harness.pane.dispatchEvent(touchEvent('touchend', [], 100));
+ expect(harness.onOpenChange).toHaveBeenCalledWith(true);
+ harness.unmount();
+ });
+
+ it('releases drag styles when an external toggle interrupts a held drag', () => {
+ const harness = setup(false);
+ harness.swipe(
+ harness.pane,
+ [
+ { x: 20, y: 100, t: 0 },
+ { x: 220, y: 100, t: 50 },
+ ],
+ false,
+ );
+ expect(harness.drawer.style.transition).toBe('none');
+
+ harness.rerender({ open: true, enabled: true });
+ expect(harness.drawer.style.transform).toBe('');
+ expect(harness.drawer.style.transition).not.toBe('none');
+ /** React committed the open pane transform before this cleanup and will
+ * not re-assert it — the release must restore it, not clear it. */
+ expect(harness.pane.style.transform).toBe('translateX(100%)');
+ harness.unmount();
+ });
+
+ it('cancels a stale settle when crossing the breakpoint disables the hook', () => {
+ jest.useFakeTimers();
+ const harness = setup(false);
+ harness.swipe(harness.pane, [
+ { x: 20, y: 100, t: 0 },
+ { x: 220, y: 100, t: 50 },
+ ]);
+ harness.rerender({ open: true, enabled: true });
+ harness.rerender({ open: true, enabled: false });
+
+ expect(harness.pane.style.transform).toBe('');
+ jest.runAllTimers();
+ expect(harness.pane.style.transform).toBe('');
+ jest.useRealTimers();
+ harness.unmount();
+ });
+
+ it('reverts when the initiating finger lifts while a second remains', () => {
+ const harness = setup(false);
+ harness.swipe(
+ harness.pane,
+ [
+ { x: 20, y: 100, t: 0 },
+ { x: 220, y: 100, t: 50 },
+ ],
+ false,
+ );
+
+ harness.pane.dispatchEvent(
+ touchEvent('touchend', [createTouch(80, 200, 1)], 80, [createTouch(220, 100, 0)]),
+ );
+
+ expect(harness.onOpenChange).not.toHaveBeenCalled();
+ expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
+ harness.unmount();
+ });
+
+ it('expires flick momentum when the finger holds still before lifting', () => {
+ const harness = setup(false);
+ harness.swipe(
+ harness.pane,
+ [
+ { x: 20, y: 100, t: 0 },
+ { x: 50, y: 100, t: 20 },
+ { x: 80, y: 100, t: 40 },
+ ],
+ false,
+ );
+ harness.pane.dispatchEvent(touchEvent('touchend', [], 600));
+
+ expect(harness.onOpenChange).not.toHaveBeenCalled();
+ expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
+ harness.unmount();
+ });
+
+ it('arms itself when enabled flips true after the surfaces exist', () => {
+ const harness = setup(false);
+ harness.rerender({ open: false, enabled: false });
+ harness.swipe(harness.pane, [
+ { x: 20, y: 100, t: 0 },
+ { x: 220, y: 100, t: 50 },
+ ]);
+ expect(harness.onOpenChange).not.toHaveBeenCalled();
+
+ harness.rerender({ open: false, enabled: true });
+ harness.swipe(harness.pane, [
+ { x: 20, y: 100, t: 200 },
+ { x: 220, y: 100, t: 250 },
+ ]);
+ expect(harness.onOpenChange).toHaveBeenCalledWith(true);
+ harness.unmount();
+ });
+});
+
+describe('findHorizontalScrollBlocker', () => {
+ const scroller = (scrollLeft: number) => {
+ const el = document.createElement('div');
+ el.style.overflowX = 'auto';
+ Object.defineProperty(el, 'scrollWidth', { value: 600, configurable: true });
+ Object.defineProperty(el, 'clientWidth', { value: 300, configurable: true });
+ el.scrollLeft = scrollLeft;
+ return el;
+ };
+
+ it('is direction-aware at either edge', () => {
+ const boundary = document.createElement('div');
+ const atStart = scroller(0);
+ const child = document.createElement('span');
+ atStart.append(child);
+ boundary.append(atStart);
+
+ expect(findHorizontalScrollBlocker(child, boundary, 1)).toBeNull();
+ expect(findHorizontalScrollBlocker(child, boundary, -1)).toBe(atStart);
+
+ const atEnd = scroller(300);
+ const endChild = document.createElement('span');
+ atEnd.append(endChild);
+ boundary.append(atEnd);
+
+ expect(findHorizontalScrollBlocker(endChild, boundary, 1)).toBe(atEnd);
+ expect(findHorizontalScrollBlocker(endChild, boundary, -1)).toBeNull();
+ });
+
+ it('normalizes RTL scroll offsets before deciding', () => {
+ const boundary = document.createElement('div');
+ const rtl = scroller(0);
+ rtl.style.direction = 'rtl';
+ const child = document.createElement('span');
+ rtl.append(child);
+ boundary.append(rtl);
+
+ expect(findHorizontalScrollBlocker(child, boundary, 1)).toBe(rtl);
+ expect(findHorizontalScrollBlocker(child, boundary, -1)).toBeNull();
+
+ rtl.scrollLeft = -300;
+ expect(findHorizontalScrollBlocker(child, boundary, 1)).toBeNull();
+ expect(findHorizontalScrollBlocker(child, boundary, -1)).toBe(rtl);
+ });
+
+ it('stops searching at the boundary element', () => {
+ const outer = scroller(120);
+ const boundary = document.createElement('div');
+ const child = document.createElement('span');
+ boundary.append(child);
+ outer.append(boundary);
+
+ expect(findHorizontalScrollBlocker(child, boundary, 1)).toBeNull();
+ });
+});
diff --git a/client/src/hooks/Nav/useDrawerSwipe.ts b/client/src/hooks/Nav/useDrawerSwipe.ts
new file mode 100644
index 0000000000..6e59a9006a
--- /dev/null
+++ b/client/src/hooks/Nav/useDrawerSwipe.ts
@@ -0,0 +1,404 @@
+import { useEffect, useRef } from 'react';
+import {
+ TRANSITION_MS,
+ MOBILE_DRAWER_ID,
+ SIDEBAR_TRANSITION,
+} from '~/components/UnifiedSidebar/constants';
+
+/** Horizontal travel before the gesture claims the touch (also the tap filter). */
+const ACTIVATION_DISTANCE = 10;
+/** |dx| must beat |dy| by this ratio to claim; otherwise vertical scroll wins. */
+const AXIS_LOCK_RATIO = 1.5;
+/** Fraction of the drawer width past which release commits the open/close. */
+const COMMIT_DISTANCE_RATIO = 0.35;
+/** px/ms — a flick past this commits regardless of distance… */
+const COMMIT_VELOCITY = 0.3;
+/** …but never from a twitch shorter than this. */
+const FLICK_MIN_DISTANCE = 24;
+/** A release this long after the last move is a hold, not a flick. */
+const VELOCITY_HOLD_MS = 100;
+/** Inline styles are cleared this long after TRANSITION_MS, then classes own the state. */
+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"]';
+
+/**
+ * Walks from `start` up to `boundary` looking for a horizontal scroller that
+ * can still consume a pan in `direction` (1 = finger moving right, which
+ * reveals content to the left, i.e. needs `scrollLeft > 0`). A rightward
+ * swipe inside a code block only defers to the block while it can actually
+ * scroll that way — parked at its edge, the drawer may claim the drag.
+ */
+export function findHorizontalScrollBlocker(
+ start: Element | null,
+ boundary: Element,
+ direction: 1 | -1,
+): Element | null {
+ let node: Element | null = start;
+ while (node != null) {
+ if (node instanceof HTMLElement && node.scrollWidth > node.clientWidth + 1) {
+ const style = window.getComputedStyle(node);
+ if (style.overflowX === 'auto' || style.overflowX === 'scroll') {
+ const maxScroll = node.scrollWidth - node.clientWidth;
+ /** RTL scrollers report `scrollLeft` in [-max, 0] with 0 at the right
+ * edge; normalize to visual distance-from-left-edge so the finger
+ * direction maps the same way in both document directions. */
+ const fromLeftEdge =
+ style.direction === 'rtl' ? node.scrollLeft + maxScroll : node.scrollLeft;
+ const remaining = direction === 1 ? fromLeftEdge : maxScroll - fromLeftEdge;
+ if (remaining > 1) {
+ return node;
+ }
+ }
+ }
+ if (node === boundary) {
+ return null;
+ }
+ node = node.parentElement;
+ }
+ return null;
+}
+
+type DrawerSwipeOptions = {
+ /** The chat pane that mirrors the drawer's motion (Root's Outlet wrapper). */
+ paneRef: React.RefObject
;
+ enabled: boolean;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+
+type Sample = { t: number; x: number };
+
+type Gesture = {
+ phase: 'tracking' | 'claimed' | 'dead';
+ opening: boolean;
+ /** Identifier of the initiating touch — a second finger never becomes it. */
+ touchId: number;
+ startX: number;
+ startY: number;
+ dx: number;
+ width: number;
+ reducedMotion: boolean;
+ drawer: HTMLElement;
+ pane: HTMLElement;
+ prevSample: Sample;
+ lastSample: Sample;
+ raf: number | null;
+ /** Set before scheduling so coalescing holds even if rAF fires synchronously. */
+ rafScheduled: boolean;
+};
+
+/** Returns every transient inline property to what React/classes render for
+ * `paneOpen`. The drawer's transform is class-driven so clearing suffices, but
+ * the pane's is a React style prop React will NOT re-assert while its value is
+ * unchanged — an open pane must get its committed transform back explicitly. */
+const releaseInlineStyles = (drawer: HTMLElement, pane: HTMLElement, paneOpen: boolean) => {
+ drawer.style.transform = '';
+ drawer.style.willChange = '';
+ drawer.style.transition = SIDEBAR_TRANSITION;
+ pane.style.transform = paneOpen ? 'translateX(100%)' : '';
+ pane.style.willChange = '';
+ pane.style.transition = SIDEBAR_TRANSITION;
+};
+
+const dragTransforms = (gesture: Gesture): { drawer: string; pane: string } => {
+ const progress = gesture.opening
+ ? Math.min(Math.max(gesture.dx, 0), gesture.width)
+ : Math.min(Math.max(gesture.dx, -gesture.width), 0) + gesture.width;
+ return {
+ drawer: `translate3d(${progress - gesture.width}px, 0, 0)`,
+ pane: `translate3d(${progress}px, 0, 0)`,
+ };
+};
+
+/**
+ * Follow-the-finger swipe between the mobile drawer and the chat pane, which
+ * move as one object (see SIDEBAR_TRANSITION). Built on touch events with a
+ * selectively non-passive `touchmove` — `touch-action` on the app shell would
+ * silently kill every horizontal scroller in the chat (code blocks, tables,
+ * carousels), and React's own onTouchMove registers passive, so
+ * `preventDefault` there is a no-op. During a drag both elements get direct
+ * rAF-coalesced transform writes and no React state changes; release animates
+ * to the nearest state and only then flips `useSidebarState`.
+ *
+ * Open listens on the pane (portaled overlays mount to `body`, outside it) and
+ * close on the drawer, which carries `touch-pan-y` since it has no horizontal
+ * scrollers of its own. On an iOS Safari tab the system back gesture owns the
+ * outer edge and is not preventable, which is exactly why activation is the
+ * whole pane rather than an edge zone; installed standalone, the edge is ours.
+ */
+export default function useDrawerSwipe({
+ paneRef,
+ enabled,
+ open,
+ onOpenChange,
+}: DrawerSwipeOptions) {
+ const gestureRef = useRef(null);
+ const settleRef = useRef<{
+ id: ReturnType;
+ target: boolean;
+ drawer: HTMLElement;
+ pane: HTMLElement;
+ } | null>(null);
+ const onOpenChangeRef = useRef(onOpenChange);
+ onOpenChangeRef.current = onOpenChange;
+
+ useEffect(() => {
+ /** A settle whose target the world has since contradicted — drawer closed
+ * via a button inside its 380ms window, or the breakpoint crossed and
+ * disabled the hook — must not fire: its stale write would strand the
+ * pane offscreen with React believing nothing changed. A settle that
+ * AGREES with `open` keeps running so its handoff finishes undisturbed. */
+ const pending = settleRef.current;
+ if (pending != null && (!enabled || pending.target !== open)) {
+ clearTimeout(pending.id);
+ settleRef.current = null;
+ releaseInlineStyles(pending.drawer, pending.pane, enabled && open);
+ }
+ /** A gesture surviving into a new effect run was interrupted — its
+ * listeners are gone, so its drag styles must resolve to the CURRENT
+ * state (this closure knows it; the torn-down one did not). */
+ const abandoned = gestureRef.current;
+ if (abandoned != null) {
+ if (abandoned.phase === 'claimed' && !abandoned.reducedMotion) {
+ releaseInlineStyles(abandoned.drawer, abandoned.pane, enabled && open);
+ }
+ if (abandoned.raf != null) {
+ cancelAnimationFrame(abandoned.raf);
+ }
+ gestureRef.current = null;
+ }
+ if (!enabled) {
+ return;
+ }
+ const pane = paneRef.current;
+ const drawer = document.getElementById(MOBILE_DRAWER_ID);
+ if (pane == null || drawer == null) {
+ return;
+ }
+ const opening = !open;
+ const surface = opening ? pane : drawer;
+
+ const clearDrag = (gesture: Gesture) => {
+ if (gesture.raf != null) {
+ cancelAnimationFrame(gesture.raf);
+ }
+ gestureRef.current = null;
+ };
+
+ /** 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) => {
+ clearDrag(gesture);
+ if (gesture.reducedMotion) {
+ if (next !== open) {
+ /** Snap: suppress the shared 300ms transition for this state flip,
+ * then hand it back once the render has committed. */
+ gesture.drawer.style.transition = 'none';
+ gesture.pane.style.transition = 'none';
+ onOpenChangeRef.current(next);
+ const id = setTimeout(() => {
+ settleRef.current = null;
+ gesture.drawer.style.transition = SIDEBAR_TRANSITION;
+ gesture.pane.style.transition = SIDEBAR_TRANSITION;
+ }, SETTLE_BUFFER_MS);
+ settleRef.current = { id, target: next, drawer: gesture.drawer, pane: gesture.pane };
+ }
+ return;
+ }
+ const { drawer: drawerEl, pane: paneEl } = gesture;
+ drawerEl.style.transition = SIDEBAR_TRANSITION;
+ paneEl.style.transition = SIDEBAR_TRANSITION;
+ drawerEl.style.transform = next ? 'translate3d(0, 0, 0)' : 'translate3d(-100%, 0, 0)';
+ paneEl.style.transform = next ? 'translateX(100%)' : 'translate3d(0, 0, 0)';
+ 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 };
+ };
+
+ const onTouchStart = (event: TouchEvent) => {
+ if (gestureRef.current != null || settleRef.current != null || event.touches.length !== 1) {
+ return;
+ }
+ const target = event.target as Element | null;
+ if (target?.closest(TEXT_SURFACE_SELECTOR) != null) {
+ return;
+ }
+ const touch = event.touches[0];
+ const sample = { t: event.timeStamp, x: touch.clientX };
+ gestureRef.current = {
+ phase: 'tracking',
+ opening,
+ touchId: touch.identifier,
+ startX: touch.clientX,
+ startY: touch.clientY,
+ dx: 0,
+ width: drawer.clientWidth || window.innerWidth,
+ reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
+ drawer,
+ pane,
+ prevSample: sample,
+ lastSample: sample,
+ raf: null,
+ rafScheduled: false,
+ };
+ };
+
+ const scheduleDragFrame = (gesture: Gesture) => {
+ if (gesture.reducedMotion || gesture.rafScheduled) {
+ return;
+ }
+ gesture.rafScheduled = true;
+ gesture.raf = requestAnimationFrame(() => {
+ gesture.rafScheduled = false;
+ gesture.raf = null;
+ const transforms = dragTransforms(gesture);
+ gesture.drawer.style.transform = transforms.drawer;
+ gesture.pane.style.transform = transforms.pane;
+ });
+ };
+
+ const onTouchMove = (event: TouchEvent) => {
+ const gesture = gestureRef.current;
+ if (gesture == null || gesture.phase === 'dead') {
+ return;
+ }
+ const touch = Array.from(event.touches).find(
+ (candidate) => candidate.identifier === gesture.touchId,
+ );
+ if (event.touches.length !== 1 || touch == null) {
+ if (gesture.phase === 'claimed') {
+ settle(gesture, open);
+ } else {
+ clearDrag(gesture);
+ }
+ return;
+ }
+ const dx = touch.clientX - gesture.startX;
+ const dy = touch.clientY - gesture.startY;
+
+ if (gesture.phase === 'tracking') {
+ const horizontal = Math.abs(dx) >= ACTIVATION_DISTANCE;
+ const wins = Math.abs(dx) >= Math.abs(dy) * AXIS_LOCK_RATIO;
+ if (horizontal && wins) {
+ const direction = dx > 0 ? 1 : -1;
+ const wrongWay = gesture.opening ? direction !== 1 : direction !== -1;
+ const blocker = wrongWay
+ ? null
+ : findHorizontalScrollBlocker(event.target as Element | null, surface, direction);
+ if (wrongWay || blocker != null) {
+ gesture.phase = 'dead';
+ return;
+ }
+ gesture.phase = 'claimed';
+ if (!gesture.reducedMotion) {
+ gesture.drawer.style.transition = 'none';
+ gesture.pane.style.transition = 'none';
+ gesture.drawer.style.willChange = 'transform';
+ gesture.pane.style.willChange = 'transform';
+ }
+ } else if (Math.abs(dy) >= ACTIVATION_DISTANCE) {
+ gesture.phase = 'dead';
+ return;
+ } else {
+ return;
+ }
+ }
+
+ event.preventDefault();
+ gesture.dx = dx;
+ gesture.prevSample = gesture.lastSample;
+ gesture.lastSample = { t: event.timeStamp, x: touch.clientX };
+ scheduleDragFrame(gesture);
+ };
+
+ const onTouchEnd = (event: TouchEvent) => {
+ const gesture = gestureRef.current;
+ if (gesture == null) {
+ return;
+ }
+ /** Only the initiating finger's lift ends the gesture; a second finger
+ * lifting is not a release. */
+ const initiatingEnded = Array.from(event.changedTouches).some(
+ (candidate) => candidate.identifier === gesture.touchId,
+ );
+ if (!initiatingEnded) {
+ return;
+ }
+ if (gesture.phase !== 'claimed') {
+ clearDrag(gesture);
+ return;
+ }
+ /** The initiating finger left while another remains — ambiguous, so
+ * revert rather than let the survivor inherit half a gesture. */
+ if (event.touches.length > 0) {
+ settle(gesture, open);
+ return;
+ }
+ const progress = gesture.opening ? gesture.dx : -gesture.dx;
+ const elapsed = gesture.lastSample.t - gesture.prevSample.t;
+ /** A flick's momentum expires if the finger holds still before lifting —
+ * velocity is only trusted when release follows the last move promptly. */
+ const held = event.timeStamp - gesture.lastSample.t > VELOCITY_HOLD_MS;
+ const velocity =
+ !held && elapsed > 0 ? (gesture.lastSample.x - gesture.prevSample.x) / elapsed : 0;
+ const towardTarget = gesture.opening ? velocity : -velocity;
+ const committed =
+ progress >= gesture.width * COMMIT_DISTANCE_RATIO ||
+ (towardTarget >= COMMIT_VELOCITY && progress >= FLICK_MIN_DISTANCE);
+ settle(gesture, committed ? gesture.opening : open);
+ };
+
+ const onTouchCancel = () => {
+ const gesture = gestureRef.current;
+ if (gesture == null) {
+ return;
+ }
+ if (gesture.phase === 'claimed') {
+ settle(gesture, open);
+ } else {
+ clearDrag(gesture);
+ }
+ };
+
+ surface.addEventListener('touchstart', onTouchStart, { passive: true });
+ surface.addEventListener('touchmove', onTouchMove, { passive: false });
+ surface.addEventListener('touchend', onTouchEnd, { passive: true });
+ surface.addEventListener('touchcancel', onTouchCancel, { passive: true });
+ return () => {
+ surface.removeEventListener('touchstart', onTouchStart);
+ surface.removeEventListener('touchmove', onTouchMove);
+ surface.removeEventListener('touchend', onTouchEnd);
+ surface.removeEventListener('touchcancel', onTouchCancel);
+ /** Interrupted gestures are resolved by the NEXT effect run, which
+ * knows the new state; here only the frame is cancelled so nothing
+ * writes after teardown. Unmount discards the DOM with its styles. */
+ const gesture = gestureRef.current;
+ if (gesture?.raf != null) {
+ cancelAnimationFrame(gesture.raf);
+ gesture.raf = null;
+ gesture.rafScheduled = false;
+ }
+ };
+ }, [enabled, open, paneRef]);
+
+ useEffect(
+ () => () => {
+ if (settleRef.current != null) {
+ clearTimeout(settleRef.current.id);
+ }
+ },
+ [],
+ );
+}
diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx
index a3bfaa9892..7253f61f84 100644
--- a/client/src/routes/Root.tsx
+++ b/client/src/routes/Root.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef, useCallback, startTransition } from 'react';
import { Outlet } from 'react-router-dom';
import {
PromptGroupsProvider,
@@ -18,9 +18,11 @@ 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';
+import useDrawerSwipe from '~/hooks/Nav/useDrawerSwipe';
import { useHealthCheck } from '~/data-provider';
import { Banner } from '~/components/Banners';
@@ -39,10 +41,39 @@ export default function Root() {
const [showTerms, setShowTerms] = useState(false);
const [bannerHeight, setBannerHeight] = useState(0);
/** Shared with the drawer so the two agree on the breakpoint-transition frame. */
- const { isSmallScreen, expanded: sidebarExpanded } = useSidebarState();
-
+ const {
+ isSmallScreen,
+ expanded: sidebarExpanded,
+ setExpanded: setSidebarExpanded,
+ } = useSidebarState();
+ const paneRef = useRef(null);
+ 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],
+ );
const { isAuthenticated, logout } = useAuthContext();
+ useDrawerSwipe({
+ paneRef,
+ /** Auth gates the whole tree below (`return null`), so the swipe surfaces
+ * only exist once authenticated — enabling earlier would attach to
+ * nothing and never re-run when they mount. */
+ enabled: isSmallScreen && isAuthenticated,
+ open: sidebarExpanded,
+ onOpenChange: handleDrawerOpenChange,
+ });
+
useHealthCheck(isAuthenticated);
const assistantsMap = useAssistantsMap({ isAuthenticated });
@@ -86,6 +117,7 @@ export default function Root() {