From 96e1915d769fc2c627e13b5124434296d0689374 Mon Sep 17 00:00:00 2001
From: Tobias Graf <2226232+42tg@users.noreply.github.com>
Date: Tue, 24 Mar 2026 14:53:19 +0100
Subject: [PATCH] feat: add mobile swipe gestures for sidebar navigation
- Add sidebar.swipe.logic.ts with pure state machine for horizontal
swipe and vertical pull-down gesture detection
- Wrap mobile sidebar sheet with SwipeToDismiss for drag-to-close
- Detect edge swipes in SidebarInset to open sidebar
- Add PullToReveal component for pull-down gesture feedback
- Auto-close mobile sidebar on thread selection
- 17 new tests for gesture logic
---
apps/web/src/components/Sidebar.tsx | 16 +-
.../components/ui/sidebar.swipe.logic.test.ts | 125 +++++++++++
.../src/components/ui/sidebar.swipe.logic.ts | 72 ++++++
apps/web/src/components/ui/sidebar.tsx | 211 +++++++++++++++++-
4 files changed, 418 insertions(+), 6 deletions(-)
create mode 100644 apps/web/src/components/ui/sidebar.swipe.logic.test.ts
create mode 100644 apps/web/src/components/ui/sidebar.swipe.logic.ts
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index fbe4e0528a..4f96070e06 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -106,6 +106,7 @@ import {
SidebarMenuSubItem,
SidebarSeparator,
SidebarTrigger,
+ useSidebar,
} from "./ui/sidebar";
import { useThreadSelectionStore } from "../threadSelectionStore";
import { isNonEmpty as isNonEmptyString } from "effect/String";
@@ -666,6 +667,7 @@ function SortableProjectItem({
}
export default function Sidebar() {
+ const { isMobile, setOpenMobile } = useSidebar();
const projects = useStore((store) => store.projects);
const sidebarThreadsById = useStore((store) => store.sidebarThreadsById);
const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId);
@@ -1203,6 +1205,9 @@ export default function Sidebar() {
clearSelection();
}
setSelectionAnchor(threadId);
+ if (isMobile) {
+ setOpenMobile(false);
+ }
void navigate({
to: "/$threadId",
params: { threadId },
@@ -1210,9 +1215,11 @@ export default function Sidebar() {
},
[
clearSelection,
+ isMobile,
navigate,
rangeSelectTo,
selectedThreadIds.size,
+ setOpenMobile,
setSelectionAnchor,
toggleThreadSelection,
],
@@ -1224,12 +1231,15 @@ export default function Sidebar() {
clearSelection();
}
setSelectionAnchor(threadId);
+ if (isMobile) {
+ setOpenMobile(false);
+ }
void navigate({
to: "/$threadId",
params: { threadId },
});
},
- [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor],
+ [clearSelection, isMobile, navigate, selectedThreadIds.size, setOpenMobile, setSelectionAnchor],
);
const handleProjectContextMenu = useCallback(
@@ -2004,7 +2014,7 @@ export default function Sidebar() {
) : (
<>
-
+
{showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
@@ -2164,7 +2174,7 @@ export default function Sidebar() {
)}
-
+
diff --git a/apps/web/src/components/ui/sidebar.swipe.logic.test.ts b/apps/web/src/components/ui/sidebar.swipe.logic.test.ts
new file mode 100644
index 0000000000..b6799a0583
--- /dev/null
+++ b/apps/web/src/components/ui/sidebar.swipe.logic.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ resolvePullGestureState,
+ resolveSwipeGestureState,
+ SCROLL_CANCEL_PX,
+ SWIPE_AXIS_RATIO,
+ SWIPE_LOCK_PX,
+} from "./sidebar.swipe.logic";
+
+describe("resolveSwipeGestureState", () => {
+ describe("terminal states pass through unchanged", () => {
+ it("returns swiping unchanged when already swiping", () => {
+ // Any delta — even one that would cancel — should not override a committed state
+ expect(resolveSwipeGestureState("swiping", { dx: 0, dy: SCROLL_CANCEL_PX + 1 })).toBe(
+ "swiping",
+ );
+ });
+
+ it("returns cancelled unchanged when already cancelled", () => {
+ // Any delta — even one that would lock-in — should not override a committed state
+ expect(
+ resolveSwipeGestureState("cancelled", {
+ dx: SWIPE_LOCK_PX + 1,
+ dy: 0,
+ }),
+ ).toBe("cancelled");
+ });
+ });
+
+ describe("scroll cancellation", () => {
+ it("cancels when vertical movement exceeds SCROLL_CANCEL_PX", () => {
+ expect(resolveSwipeGestureState("idle", { dx: 0, dy: SCROLL_CANCEL_PX + 1 })).toBe(
+ "cancelled",
+ );
+ });
+
+ it("does not cancel when vertical movement is exactly at SCROLL_CANCEL_PX", () => {
+ // Rule uses strict >, so equality stays idle
+ expect(resolveSwipeGestureState("idle", { dx: 0, dy: SCROLL_CANCEL_PX })).toBe("idle");
+ });
+
+ it("cancels even when horizontal movement would otherwise qualify for lock-in", () => {
+ // Vertical exceeds cancel threshold, so cancel wins regardless of horizontal
+ expect(
+ resolveSwipeGestureState("idle", {
+ dx: SWIPE_LOCK_PX + 1,
+ dy: SCROLL_CANCEL_PX + 1,
+ }),
+ ).toBe("cancelled");
+ });
+ });
+
+ describe("swipe lock-in", () => {
+ it("locks in when horizontal exceeds SWIPE_LOCK_PX and is more than 2x the vertical", () => {
+ const dy = 5;
+ const dx = dy * SWIPE_AXIS_RATIO + SWIPE_LOCK_PX; // satisfies both distance and ratio
+ expect(resolveSwipeGestureState("idle", { dx, dy })).toBe("swiping");
+ });
+
+ it("does not lock in when horizontal is exactly at SWIPE_LOCK_PX", () => {
+ // Rule uses strict >, so equality stays idle
+ expect(resolveSwipeGestureState("idle", { dx: SWIPE_LOCK_PX, dy: 0 })).toBe("idle");
+ });
+
+ it("does not lock in when horizontal is not 2x the vertical", () => {
+ // dy = SCROLL_CANCEL_PX → no cancel triggered (strict >)
+ // dx = SWIPE_LOCK_PX + 2 → exceeds distance threshold
+ // but SWIPE_LOCK_PX + 2 (22) is NOT > SCROLL_CANCEL_PX * SWIPE_AXIS_RATIO (24) → ratio fails
+ const dy = SCROLL_CANCEL_PX;
+ const dx = SWIPE_LOCK_PX + 2;
+ expect(resolveSwipeGestureState("idle", { dx, dy })).toBe("idle");
+ });
+
+ it("does not lock in when only the axis ratio is met but distance is insufficient", () => {
+ // dx=10 satisfies ratio (dy=4, 10 > 4*2=8) but dx(10) is NOT > SWIPE_LOCK_PX(20)
+ expect(resolveSwipeGestureState("idle", { dx: 10, dy: 4 })).toBe("idle");
+ });
+ });
+
+ describe("idle state", () => {
+ it("stays idle for small diagonal movement", () => {
+ // Both dx and dy are small — well under all thresholds
+ expect(resolveSwipeGestureState("idle", { dx: 5, dy: 3 })).toBe("idle");
+ });
+
+ it("stays idle at zero delta", () => {
+ expect(resolveSwipeGestureState("idle", { dx: 0, dy: 0 })).toBe("idle");
+ });
+ });
+});
+
+describe("resolvePullGestureState", () => {
+ describe("terminal states pass through unchanged", () => {
+ it("returns pulling unchanged when already pulling", () => {
+ expect(resolvePullGestureState("pulling", { dx: SWIPE_LOCK_PX + 1, dy: 0 })).toBe("pulling");
+ });
+
+ it("returns cancelled unchanged when already cancelled", () => {
+ expect(resolvePullGestureState("cancelled", { dx: 0, dy: 100 })).toBe("cancelled");
+ });
+ });
+
+ describe("cancellation", () => {
+ it("cancels when horizontal movement exceeds SWIPE_LOCK_PX", () => {
+ expect(resolvePullGestureState("idle", { dx: SWIPE_LOCK_PX + 1, dy: 30 })).toBe("cancelled");
+ });
+
+ it("cancels when vertical movement is upward", () => {
+ expect(resolvePullGestureState("idle", { dx: 0, dy: -1 })).toBe("cancelled");
+ });
+ });
+
+ describe("pull lock-in", () => {
+ it("locks in when downward exceeds threshold and dominates horizontal", () => {
+ expect(resolvePullGestureState("idle", { dx: 2, dy: 20 })).toBe("pulling");
+ });
+ });
+
+ describe("idle state", () => {
+ it("stays idle for small movement", () => {
+ expect(resolvePullGestureState("idle", { dx: 2, dy: 5 })).toBe("idle");
+ });
+ });
+});
diff --git a/apps/web/src/components/ui/sidebar.swipe.logic.ts b/apps/web/src/components/ui/sidebar.swipe.logic.ts
new file mode 100644
index 0000000000..1080e60822
--- /dev/null
+++ b/apps/web/src/components/ui/sidebar.swipe.logic.ts
@@ -0,0 +1,72 @@
+export const SWIPE_THRESHOLD = 80;
+// Minimum horizontal movement before we consider it a swipe intent
+export const SWIPE_LOCK_PX = 20;
+// Horizontal movement must be this many times greater than vertical to count as a swipe
+export const SWIPE_AXIS_RATIO = 2;
+// If vertical movement exceeds this before horizontal lock-in, treat as a scroll and cancel
+export const SCROLL_CANCEL_PX = 12;
+
+export type SwipeGestureState = "idle" | "swiping" | "cancelled";
+
+/**
+ * Pure state transition function for swipe gesture detection.
+ * Given the current gesture state and cumulative delta from touch start,
+ * returns the next state.
+ *
+ * Rules (evaluated in order):
+ * 1. Already committed (swiping or cancelled) → state is terminal, return as-is
+ * 2. Vertical movement exceeds SCROLL_CANCEL_PX → treat as scroll, cancel
+ * 3. Horizontal movement exceeds SWIPE_LOCK_PX and is 2× the vertical → lock in as swipe
+ * 4. Otherwise → remain idle
+ */
+export function resolveSwipeGestureState(
+ current: SwipeGestureState,
+ delta: { dx: number; dy: number },
+): SwipeGestureState {
+ if (current !== "idle") return current;
+
+ if (Math.abs(delta.dy) > SCROLL_CANCEL_PX) return "cancelled";
+
+ if (
+ Math.abs(delta.dx) > SWIPE_LOCK_PX &&
+ Math.abs(delta.dx) > Math.abs(delta.dy) * SWIPE_AXIS_RATIO
+ ) {
+ return "swiping";
+ }
+
+ return "idle";
+}
+
+// --- Pull-to-reveal (vertical) gesture ---
+
+export const PULL_THRESHOLD = 64;
+// Minimum downward movement before locking in as a pull gesture
+const PULL_LOCK_PY = 16;
+// Vertical movement must be this many times greater than horizontal
+const PULL_AXIS_RATIO = 1.5;
+
+export type PullGestureState = "idle" | "pulling" | "cancelled";
+
+/**
+ * Pure state transition for pull-down gesture detection.
+ * Mirrors resolveSwipeGestureState but for the vertical axis.
+ *
+ * Rules:
+ * 1. Already committed → terminal, return as-is
+ * 2. Horizontal movement too large → treat as swipe, cancel
+ * 3. Upward movement → cancel
+ * 4. Downward movement exceeds lock-in and dominates horizontal → lock in as pull
+ * 5. Otherwise → remain idle
+ */
+export function resolvePullGestureState(
+ current: PullGestureState,
+ delta: { dx: number; dy: number },
+): PullGestureState {
+ if (current !== "idle") return current;
+ if (Math.abs(delta.dx) > SWIPE_LOCK_PX) return "cancelled";
+ if (delta.dy < 0) return "cancelled";
+ if (delta.dy > PULL_LOCK_PY && delta.dy > Math.abs(delta.dx) * PULL_AXIS_RATIO) {
+ return "pulling";
+ }
+ return "idle";
+}
diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx
index cfa29e950b..02c865b47f 100644
--- a/apps/web/src/components/ui/sidebar.tsx
+++ b/apps/web/src/components/ui/sidebar.tsx
@@ -20,6 +20,11 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
import { Schema } from "effect";
+import {
+ resolveSwipeGestureState,
+ SWIPE_THRESHOLD,
+ type SwipeGestureState,
+} from "./sidebar.swipe.logic";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
@@ -85,6 +90,124 @@ function useSidebar() {
return context;
}
+function SwipeToDismiss({
+ children,
+ onDismiss,
+ side,
+}: {
+ children: React.ReactNode;
+ onDismiss: () => void;
+ side: "left" | "right";
+}) {
+ const containerRef = React.useRef(null);
+ const touchStartRef = React.useRef<{ x: number; y: number } | null>(null);
+ const gestureStateRef = React.useRef("idle");
+
+ const applyTransform = React.useCallback(
+ (dx: number) => {
+ const el = containerRef.current;
+ if (!el) return;
+ // Only translate in the closing direction
+ const offset = side === "left" ? Math.min(0, dx) : Math.max(0, dx);
+ if (offset === 0) {
+ el.style.transform = "";
+ el.style.opacity = "";
+ } else {
+ el.style.transform = `translateX(${offset}px)`;
+ el.style.opacity = String(Math.max(0.4, 1 - Math.abs(offset) / 280));
+ }
+ },
+ [side],
+ );
+
+ const resetTransform = React.useCallback((animated: boolean) => {
+ const el = containerRef.current;
+ if (!el) return;
+ if (animated) {
+ el.style.transition = "transform 0.2s ease-out, opacity 0.2s ease-out";
+ el.style.transform = "";
+ el.style.opacity = "";
+ const cleanup = () => {
+ el.style.transition = "";
+ el.removeEventListener("transitionend", cleanup);
+ };
+ el.addEventListener("transitionend", cleanup);
+ } else {
+ el.style.transition = "";
+ el.style.transform = "";
+ el.style.opacity = "";
+ }
+ }, []);
+
+ const handleTouchStart = React.useCallback((e: React.TouchEvent) => {
+ const touch = e.touches[0];
+ if (!touch) return;
+ touchStartRef.current = { x: touch.clientX, y: touch.clientY };
+ gestureStateRef.current = "idle";
+ if (containerRef.current) containerRef.current.style.transition = "";
+ }, []);
+
+ const handleTouchMove = React.useCallback(
+ (e: React.TouchEvent) => {
+ const touch = e.touches[0];
+ const start = touchStartRef.current;
+ if (!touch || !start) return;
+ const dx = touch.clientX - start.x;
+ const dy = touch.clientY - start.y;
+ gestureStateRef.current = resolveSwipeGestureState(gestureStateRef.current, { dx, dy });
+ if (gestureStateRef.current === "swiping") {
+ applyTransform(dx);
+ }
+ },
+ [applyTransform],
+ );
+
+ const handleTouchEnd = React.useCallback(
+ (e: React.TouchEvent) => {
+ const touch = e.changedTouches[0];
+ const start = touchStartRef.current;
+ touchStartRef.current = null;
+
+ if (!touch || !start || gestureStateRef.current !== "swiping") {
+ resetTransform(true);
+ return;
+ }
+
+ const dx = touch.clientX - start.x;
+ // Sidebar opens from left → swipe left (negative dx) to close
+ // Sidebar opens from right → swipe right (positive dx) to close
+ const shouldDismiss = side === "left" ? dx < -SWIPE_THRESHOLD : dx > SWIPE_THRESHOLD;
+
+ if (shouldDismiss) {
+ resetTransform(false); // Sheet handles its own close animation
+ onDismiss();
+ } else {
+ resetTransform(true); // Snap back
+ }
+ },
+ [onDismiss, resetTransform, side],
+ );
+
+ const handleTouchCancel = React.useCallback(() => {
+ touchStartRef.current = null;
+ gestureStateRef.current = "idle";
+ resetTransform(true);
+ }, [resetTransform]);
+
+ return (
+
+ {children}
+
+ );
+}
+
function SidebarProvider({
defaultOpen = true,
open: openProp,
@@ -245,7 +368,9 @@ function Sidebar({
Sidebar
Displays the mobile sidebar.
- {children}
+ setOpenMobile(false)} side={side}>
+ {children}
+
@@ -593,7 +718,74 @@ function SidebarRail({
);
}
-function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+const SWIPE_OPEN_EDGE_PX = 80;
+
+function SidebarInset({ className, children, ...props }: React.ComponentProps<"main">) {
+ const { isMobile, setOpenMobile } = useSidebar();
+
+ const edgeIndicatorRef = React.useRef(null);
+ const touchStartRef = React.useRef<{ x: number; y: number } | null>(null);
+ const gestureStateRef = React.useRef("idle");
+
+ const handleTouchStart = React.useCallback(
+ (e: React.TouchEvent) => {
+ if (!isMobile) return;
+ const touch = e.touches[0];
+ if (!touch) return;
+ // Only start tracking if the touch begins near the left edge
+ if (touch.clientX > SWIPE_OPEN_EDGE_PX) return;
+ touchStartRef.current = { x: touch.clientX, y: touch.clientY };
+ gestureStateRef.current = "idle";
+ },
+ [isMobile],
+ );
+
+ const handleTouchMove = React.useCallback((e: React.TouchEvent) => {
+ const touch = e.touches[0];
+ const start = touchStartRef.current;
+ if (!touch || !start) return;
+ const dx = touch.clientX - start.x;
+ const dy = touch.clientY - start.y;
+ gestureStateRef.current = resolveSwipeGestureState(gestureStateRef.current, { dx, dy });
+ const el = edgeIndicatorRef.current;
+ if (el && gestureStateRef.current === "swiping" && dx > 0) {
+ const progress = Math.min(dx / SWIPE_THRESHOLD, 1);
+ // Reach full opacity quickly so the background looks solid
+ el.style.opacity = String(Math.min(progress * 2, 1));
+ // Follow the finger directly, cap at half screen width
+ el.style.width = `${Math.min(dx, window.innerWidth * 0.5)}px`;
+ }
+ }, []);
+
+ const handleTouchEnd = React.useCallback(
+ (e: React.TouchEvent) => {
+ const touch = e.changedTouches[0];
+ const start = touchStartRef.current;
+ touchStartRef.current = null;
+ const el = edgeIndicatorRef.current;
+ if (el) {
+ el.style.opacity = "0";
+ el.style.width = "4px";
+ }
+ if (!touch || !start || gestureStateRef.current !== "swiping") return;
+ const dx = touch.clientX - start.x;
+ if (dx > SWIPE_THRESHOLD) {
+ setOpenMobile(true);
+ }
+ },
+ [setOpenMobile],
+ );
+
+ const handleTouchCancel = React.useCallback(() => {
+ touchStartRef.current = null;
+ gestureStateRef.current = "idle";
+ const el = edgeIndicatorRef.current;
+ if (el) {
+ el.style.opacity = "0";
+ el.style.width = "4px";
+ }
+ }, []);
+
return (
) {
)}
data-slot="sidebar-inset"
{...props}
- />
+ onTouchStart={handleTouchStart}
+ onTouchMove={handleTouchMove}
+ onTouchEnd={handleTouchEnd}
+ onTouchCancel={handleTouchCancel}
+ >
+ {/* Left-edge peek that grows while swiping to open the sidebar */}
+
+ {children}
+
);
}