From 5d121602a5a71bc5eddd1df77ad5bad218d48929 Mon Sep 17 00:00:00 2001
From: Nikhil Gaddam <35731361+NikhilGaddam@users.noreply.github.com>
Date: Wed, 11 Nov 2026 19:13:54 +0000
Subject: [PATCH] feat(web): setting to turn off trackpad swipe navigation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add Settings → General → Display → Swipe to navigate (desktop, macOS). It is on by default, so behavior does not change unless a user turns it off.
---
.../HistorySwipeNavigation.browser.test.tsx | 18 ++++++++---
.../src/components/HistorySwipeNavigation.tsx | 6 ++--
.../components/settings/SettingsPanels.tsx | 32 +++++++++++++++++++
.../src/components/settings/settingsSearch.ts | 8 +++++
docs/user/keybindings.md | 2 ++
packages/contracts/src/settings.ts | 2 ++
6 files changed, 62 insertions(+), 6 deletions(-)
diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx
index 10bf817b7363f..88f01011732be 100644
--- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx
+++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx
@@ -1,11 +1,18 @@
+import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import { describe, expect, it, vi } from "vite-plus/test";
import { render } from "../../test/render";
+import { __setClientSettingsForTests } from "../hooks/useSettings";
import { HistorySwipeNavigation } from "./HistorySwipeNavigation";
const navigateHistory = vi.hoisted(() => vi.fn());
vi.mock("../navigationHistory", () => ({ canNavigateHistory: () => true, navigateHistory }));
+function setup(swipeNavigationEnabled = true) {
+ navigateHistory.mockReset();
+ __setClientSettingsForTests({ ...DEFAULT_CLIENT_SETTINGS, swipeNavigationEnabled });
+}
+
function swipe(target: Element, deltaX: number) {
for (const delta of [deltaX, 0]) {
target.dispatchEvent(new WheelEvent("wheel", { deltaX: delta, bubbles: true, composed: true }));
@@ -28,7 +35,7 @@ describe("HistorySwipeNavigation", () => {
["swiping back over a panel without overflow navigates", "none", 0, -130, true],
["a swipe that started in a closed dialog keeps the thread", "closed-dialog", 0, -130, false],
] as const)("%s", async (_name, scroller, scrollLeft, deltaX, navigates) => {
- navigateHistory.mockReset();
+ setup();
using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
await using screen = await render();
const panel = screen.container.appendChild(document.createElement("div"));
@@ -60,9 +67,12 @@ describe("HistorySwipeNavigation", () => {
expect(navigateHistory).toHaveBeenCalledTimes(navigates ? 1 : 0);
});
- it("a horizontal mouse wheel tick off macOS keeps the thread", async () => {
- navigateHistory.mockReset();
- using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Win32");
+ it.each([
+ ["a horizontal mouse wheel tick off macOS keeps the thread", "Win32", true],
+ ["a swipe with swipe navigation turned off keeps the thread", "MacIntel", false],
+ ] as const)("%s", async (_name, platform, swipeNavigationEnabled) => {
+ setup(swipeNavigationEnabled);
+ using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue(platform);
await using screen = await render();
const content = screen.container.appendChild(document.createElement("div"));
content.style.cssText = "height: 20px; width: 100px";
diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx
index e1adc04491a91..791acc08a5bde 100644
--- a/apps/web/src/components/HistorySwipeNavigation.tsx
+++ b/apps/web/src/components/HistorySwipeNavigation.tsx
@@ -1,6 +1,7 @@
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { useEffect, useState } from "react";
+import { useClientSettings } from "../hooks/useSettings";
import { cn, isMacPlatform } from "../lib/utils";
import { canNavigateHistory, navigateHistory, type HistoryDirection } from "../navigationHistory";
@@ -93,11 +94,12 @@ export function isMomentumTail(deltas: readonly number[]): boolean {
* macOS only: on Windows and Linux a mouse tilt wheel is indistinguishable from a trackpad swipe.
*/
export function HistorySwipeNavigation() {
+ const enabled = useClientSettings((settings) => settings.swipeNavigationEnabled);
const [swipe, setSwipe] = useState(null);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window wheel and desktop gesture listeners track trackpad swipes
useEffect(() => {
- if (!isMacPlatform(navigator.platform)) return;
+ if (!enabled || !isMacPlatform(navigator.platform)) return;
let gesture: SwipeGesture | null = null;
const hideIndicator = () =>
setSwipe((current) => (current === null ? null : { ...current, tracking: false }));
@@ -152,7 +154,7 @@ export function HistorySwipeNavigation() {
unsubscribeScrollEnd?.();
window.removeEventListener("wheel", onWheel, { capture: true });
};
- }, []);
+ }, [enabled]);
return swipe === null ? null : ;
}
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index dcaca88e6c7f7..55e8d4919fb67 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -493,6 +493,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.sidebarWorkingShelfEnabled !== DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled
? ["Working section"]
: []),
+ ...(settings.swipeNavigationEnabled !== DEFAULT_UNIFIED_SETTINGS.swipeNavigationEnabled
+ ? ["Swipe to navigate"]
+ : []),
...(settings.sidebarAutoSettleAfterDays !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays
? ["Auto-settle inactive threads"]
: []),
@@ -666,6 +669,7 @@ export function useSettingsRestore(onRestored?: () => void) {
sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder,
sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,
+ swipeNavigationEnabled: DEFAULT_UNIFIED_SETTINGS.swipeNavigationEnabled,
sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
sidebarAutoSettleKeepPullRequests: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleKeepPullRequests,
sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
@@ -1049,6 +1053,34 @@ export function GeneralSettingsPanel() {
}
/>
+ {isElectron && isMacPlatform(navigator.platform) ? (
+
+ updateSettings({
+ swipeNavigationEnabled: DEFAULT_UNIFIED_SETTINGS.swipeNavigationEnabled,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ updateSettings({ swipeNavigationEnabled: Boolean(checked) })
+ }
+ aria-label="Swipe to navigate"
+ />
+ }
+ />
+ ) : null}
);
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 4f61ba41c0d86..2468f660557d9 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -684,6 +684,14 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"],
},
+ {
+ id: "swipe-navigation",
+ title: "Swipe to navigate",
+ to: "/settings/general",
+ searchTerms: ["trackpad two finger gesture back forward history thread"],
+ desktopOnly: true,
+ macOnly: true,
+ },
{
id: "response-streaming",
title: "Response streaming",
diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md
index 0449990ee0b2b..f6097dd155fdc 100644
--- a/docs/user/keybindings.md
+++ b/docs/user/keybindings.md
@@ -184,6 +184,8 @@ field gets its own undo back. Terminals always keep their native shortcut.
`navigation.back` (`mod+[` by default) and `navigation.forward` (`mod+]`) move
through the pages you have visited, like a browser's back and forward buttons.
+In the desktop app on macOS, a two-finger trackpad swipe does the same. Turn it off
+with **Settings → General → Display → Swipe to navigate**.
`chat.new` may ask you to choose a project when there is more than one.
`chat.newLocal` skips that chooser. Both use your
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 346213992e4b2..3536b8805eb22 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -532,6 +532,7 @@ export const ClientSettingsSchema = Schema.Struct({
Schema.withDecodingDefault(Effect.succeed({})),
),
sidebarWorkingShelfEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
+ swipeNavigationEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
),
@@ -2040,6 +2041,7 @@ export const ClientSettingsPatch = Schema.Struct({
threadDetailsUsageEnabled: Schema.optionalKey(Schema.Boolean),
threadDetailsItems: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)),
sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean),
+ swipeNavigationEnabled: Schema.optionalKey(Schema.Boolean),
sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
sidebarProjectGroupingOverrides: Schema.optionalKey(
Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),