Skip to content
Closed
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
18 changes: 14 additions & 4 deletions apps/web/src/components/HistorySwipeNavigation.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }));
Expand All @@ -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(<HistorySwipeNavigation />);
const panel = screen.container.appendChild(document.createElement("div"));
Expand Down Expand Up @@ -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(<HistorySwipeNavigation />);
const content = screen.container.appendChild(document.createElement("div"));
content.style.cssText = "height: 20px; width: 100px";
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/HistorySwipeNavigation.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<SwipeIndicatorState | null>(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 }));
Expand Down Expand Up @@ -152,7 +154,7 @@ export function HistorySwipeNavigation() {
unsubscribeScrollEnd?.();
window.removeEventListener("wheel", onWheel, { capture: true });
};
}, []);
}, [enabled]);

return swipe === null ? null : <SwipeIndicator {...swipe} />;
}
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
: []),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1049,6 +1053,34 @@ export function GeneralSettingsPanel() {
</Select>
}
/>
{isElectron && isMacPlatform(navigator.platform) ? (
<SettingsRow
{...searchableSetting("swipe-navigation")}
description="Swipe left or right with two fingers on the trackpad to go back or forward."
resetAction={
settings.swipeNavigationEnabled !==
DEFAULT_UNIFIED_SETTINGS.swipeNavigationEnabled ? (
<SettingResetButton
label="swipe navigation"
onClick={() =>
updateSettings({
swipeNavigationEnabled: DEFAULT_UNIFIED_SETTINGS.swipeNavigationEnabled,
})
}
/>
) : null
}
control={
<Switch
checked={settings.swipeNavigationEnabled}
onCheckedChange={(checked) =>
updateSettings({ swipeNavigationEnabled: Boolean(checked) })
}
aria-label="Swipe to navigate"
/>
}
/>
) : null}
</SettingsSection>
</SettingsPageContainer>
);
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions docs/user/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
),
Expand Down Expand Up @@ -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),
Expand Down
Loading