Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import {
SidebarMenuSubItem,
SidebarSeparator,
SidebarTrigger,
useSidebar,
} from "./ui/sidebar";
import { useThreadSelectionStore } from "../threadSelectionStore";
import { isNonEmpty as isNonEmptyString } from "effect/String";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1203,16 +1205,21 @@ export default function Sidebar() {
clearSelection();
}
setSelectionAnchor(threadId);
if (isMobile) {
setOpenMobile(false);
}
void navigate({
to: "/$threadId",
params: { threadId },
});
},
[
clearSelection,
isMobile,
navigate,
rangeSelectTo,
selectedThreadIds.size,
setOpenMobile,
setSelectionAnchor,
toggleThreadSelection,
],
Expand All @@ -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(
Expand Down Expand Up @@ -2004,7 +2014,7 @@ export default function Sidebar() {
<SettingsSidebarNav pathname={pathname} />
) : (
<>
<SidebarContent className="gap-0">
<SidebarContent className="gap-0">
{showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
<SidebarGroup className="px-2 pt-2 pb-0">
<Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
Expand Down Expand Up @@ -2164,7 +2174,7 @@ export default function Sidebar() {
</div>
)}
</SidebarGroup>
</SidebarContent>
</SidebarContent>

<SidebarSeparator />
<SidebarFooter className="p-2">
Expand Down
125 changes: 125 additions & 0 deletions apps/web/src/components/ui/sidebar.swipe.logic.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
72 changes: 72 additions & 0 deletions apps/web/src/components/ui/sidebar.swipe.logic.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull gesture exports unused in production code

Low Severity

resolvePullGestureState, PULL_THRESHOLD, and PullGestureState are exported but never imported by any production code — only the test file references them. The PullToReveal component mentioned in the PR description does not exist in the codebase, making this dead code that adds maintenance surface with no consumers.

Fix in Cursor Fix in Web

Loading
Loading