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),