From db2a71b12f7d5a389f60aa0fc05f1ff56c72de54 Mon Sep 17 00:00:00 2001 From: Mateusz Choma Date: Sat, 31 Oct 2026 19:11:06 +0100 Subject: [PATCH 1/2] feat(web): add a setting to disable history swipe navigation Horizontal mouse wheels can accidentally navigate desktop thread history. Electron input events do not distinguish a trackpad swipe from a mouse wheel. Add a default-enabled client setting with desktop Settings search and reset support. Unmount the gesture handler when disabled, and cover disabling, mid-swipe cleanup, and re-enabling with focused regression tests. Model: gpt-6.1-sol via Codex CLI, orchestrated by Claude Code. --- .../AppSidebarLayout.browser.test.tsx | 67 +++++++++++++++++++ apps/web/src/components/AppSidebarLayout.tsx | 5 +- .../src/components/HistorySwipeNavigation.tsx | 1 + .../components/settings/SettingsPanels.tsx | 35 ++++++++++ .../settings/settingsSearch.test.ts | 2 + .../src/components/settings/settingsSearch.ts | 7 ++ packages/contracts/src/settings.test.ts | 13 ++++ packages/contracts/src/settings.ts | 2 + 8 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/AppSidebarLayout.browser.test.tsx diff --git a/apps/web/src/components/AppSidebarLayout.browser.test.tsx b/apps/web/src/components/AppSidebarLayout.browser.test.tsx new file mode 100644 index 0000000000000..c1e087ff9e93a --- /dev/null +++ b/apps/web/src/components/AppSidebarLayout.browser.test.tsx @@ -0,0 +1,67 @@ +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; +import { + __resetClientSettingsPersistenceForTests, + __setClientSettingsForTests, + updateClientSettings, +} from "../hooks/useSettings"; +import { AppSidebarLayout } from "./AppSidebarLayout"; + +vi.mock("../env", () => ({ isElectron: true })); +vi.mock("./Sidebar", () => ({ default: () => null })); +vi.mock("./BackgroundThreadAnnouncer", () => ({ BackgroundThreadAnnouncer: () => null })); +vi.mock("./sidebar/SidebarChrome", () => ({ SidebarBrandWidthProbe: () => null })); +vi.mock("../localApi", () => ({ + ensureLocalApi: () => ({ + persistence: { setClientSettings: async () => undefined }, + }), +})); + +function setup(historySwipeNavigation: boolean) { + __resetClientSettingsPersistenceForTests(); + __setClientSettingsForTests({ ...DEFAULT_CLIENT_SETTINGS, historySwipeNavigation }); +} + +function wheel(deltaX: number) { + window.dispatchEvent(new WheelEvent("wheel", { deltaX, clientY: 200 })); +} + +describe("desktop history swipe navigation", () => { + it.each(["back", "forward"] as const)( + "disables %s swipes and can re-enable them without reloading", + async (direction) => { + setup(false); + using _available = vi + .spyOn(window.navigation, direction === "back" ? "canGoBack" : "canGoForward", "get") + .mockReturnValue(true); + await using screen = await render(Thread); + using navigate = vi.spyOn(window.history, direction).mockImplementation(() => undefined); + const deltaX = direction === "back" ? -140 : 140; + + await act(() => { + wheel(deltaX); + wheel(0); + }); + expect(navigate).not.toHaveBeenCalled(); + await expect.element(screen.getByTestId("history-swipe-indicator")).not.toBeInTheDocument(); + + await act(() => updateClientSettings({ historySwipeNavigation: true })); + await act(() => wheel(deltaX)); + await expect.element(screen.getByTestId("history-swipe-indicator")).toBeInTheDocument(); + + await act(() => updateClientSettings({ historySwipeNavigation: false })); + await expect.element(screen.getByTestId("history-swipe-indicator")).not.toBeInTheDocument(); + await act(() => wheel(0)); + expect(navigate).not.toHaveBeenCalled(); + + await act(() => updateClientSettings({ historySwipeNavigation: true })); + await act(() => { + wheel(deltaX); + wheel(0); + }); + expect(navigate).toHaveBeenCalledOnce(); + }, + ); +}); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 91df5616f7f50..6140b77d49230 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -31,7 +31,7 @@ import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useEnvironmentIdentificationMode } from "../hooks/useSettings"; +import { useClientSettings, useEnvironmentIdentificationMode } from "../hooks/useSettings"; import { PanelAnimationSuppressionProvider, usePanelAnimationSettings, @@ -402,6 +402,7 @@ function SidebarControl() { } export function AppSidebarLayout({ children }: { children: ReactNode }) { + const historySwipeNavigation = useClientSettings((settings) => settings.historySwipeNavigation); const sidebarArea = workspaceAreaProps("sidebar", 0, "Threads"); const [sidebarRouteReveal] = useState(() => ({ observedKey: null, @@ -475,7 +476,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} - {isElectron ? : null} + {isElectron && historySwipeNavigation ? : null} ); diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index c020878b25363..80b4e2aca3c3e 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -162,6 +162,7 @@ function SwipeIndicator({ direction, progress, y, bounds, tracking }: SwipeIndic return (