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
68 changes: 64 additions & 4 deletions apps/web/src/components/AppSidebarLayout.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { page, userEvent } from "vite-plus/test/browser";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import { describe, expect, it, vi } from "vite-plus/test";

import { render } from "../../test/render";
import { act, render } from "../../test/render";
import {
__resetClientSettingsPersistenceForTests,
__setClientSettingsForTests,
updateClientSettings,
} from "../hooks/useSettings";
import { AppSidebarLayout } from "./AppSidebarLayout";

vi.mock(import("../state/server"), async (original) => {
const actual = await original();
Expand All @@ -12,10 +19,63 @@ vi.mock(import("../state/server"), async (original) => {
]);
return { ...actual, primaryServerKeybindingsAtom: Atom.make(keybindings) };
});
// The thread list needs live environments; the shortcut lives in the shell around it.
vi.mock(import("./Sidebar"), () => ({ default: (() => null) as never }));
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 },
}),
}));

import { AppSidebarLayout } from "./AppSidebarLayout";
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 _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
using _available = vi
.spyOn(window.navigation, direction === "back" ? "canGoBack" : "canGoForward", "get")
.mockReturnValue(true);
await using screen = await render(<AppSidebarLayout>Thread</AppSidebarLayout>);
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();
},
);
});

describe("sidebar toggle shortcut", () => {
it("leaves the key to a focused terminal when its rule excludes terminalFocus", async () => {
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -418,6 +418,7 @@ export function AppSidebarLayout({
children: ReactNode;
showSidebar?: boolean;
}) {
const historySwipeNavigation = useClientSettings((settings) => settings.historySwipeNavigation);
const sidebarArea = workspaceAreaProps("sidebar", 0, "Threads");
const [sidebarRouteReveal] = useState<SidebarRouteReveal>(() => ({
observedKey: null,
Expand Down Expand Up @@ -495,7 +496,7 @@ export function AppSidebarLayout({
{children}
<BackgroundThreadAnnouncer />
<SidebarControl showSidebar={showSidebar} />
{isElectron ? <HistorySwipeNavigation /> : null}
{isElectron && historySwipeNavigation ? <HistorySwipeNavigation /> : null}
</SidebarProvider>
</PanelAnimationSuppressionProvider>
);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/HistorySwipeNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ function SwipeIndicator({ direction, progress, y, bounds, tracking }: SwipeIndic
return (
<div
aria-hidden="true"
data-testid="history-swipe-indicator"
className="pointer-events-none fixed z-100 overflow-hidden"
style={bounds}
>
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,9 @@ export function useSettingsRestore(onRestored?: () => void) {
? ["Delete confirmation"]
: []),
...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit shortcut"] : []),
...(settings.historySwipeNavigation !== DEFAULT_UNIFIED_SETTINGS.historySwipeNavigation
? ["Swipe to navigate"]
: []),
...(isTextGenerationModelDirty ? ["Text generation model"] : []),
...(settings.threadTitleInstructions !== DEFAULT_UNIFIED_SETTINGS.threadTitleInstructions
? ["Thread title instructions"]
Expand Down Expand Up @@ -699,6 +702,7 @@ export function useSettingsRestore(onRestored?: () => void) {
confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin,
confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit,
historySwipeNavigation: DEFAULT_UNIFIED_SETTINGS.historySwipeNavigation,
textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
threadTitleInstructions: DEFAULT_UNIFIED_SETTINGS.threadTitleInstructions,
fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans,
Expand Down Expand Up @@ -932,6 +936,39 @@ export function GeneralSettingsPanel() {
/>
</SettingsSection>

{isElectron && isMacPlatform(navigator.platform) ? (
<SettingsSection id="navigation" title={gt("Navigation")}>
<SettingsRow
{...setting("history-swipe-navigation")}
description={
<T>Swipe horizontally with two fingers on a trackpad to go back or forward.</T>
}
resetAction={
settings.historySwipeNavigation !==
DEFAULT_UNIFIED_SETTINGS.historySwipeNavigation ? (
<SettingResetButton
label="swipe navigation"
onClick={() =>
updateSettings({
historySwipeNavigation: DEFAULT_UNIFIED_SETTINGS.historySwipeNavigation,
})
}
/>
) : null
}
control={
<Switch
checked={settings.historySwipeNavigation}
onCheckedChange={(checked) =>
updateSettings({ historySwipeNavigation: Boolean(checked) })
}
aria-label={gt("Swipe to navigate")}
/>
}
/>
</SettingsSection>
) : null}

<SettingsSection id="confirmations" title={gt("Confirmations")}>
<SettingsRow
{...setting("unpin-confirmation")}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/settings/settingsSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ describe("searchSettings", () => {

it("hides desktop-only settings from browser search", () => {
expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true);
expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "history-swipe-navigation")).toBe(true);
expect(searchSettings("horizontal wheel")).toEqual([]);
expect(searchSettings("hold to quit")).toEqual([]);
expect(searchSettings("wsl")).toEqual([]);
expect(searchSettings("update track")).toEqual([]);
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,16 @@ export const SETTINGS_SEARCH_ITEMS = [
searchTerms: ["confirmation desktop app exit direct hold double click press twice"],
desktopOnly: true,
},
{
id: "history-swipe-navigation",
title: msg("Swipe to navigate"),
to: "/settings/general",
searchTerms: [
"two-finger horizontal trackpad gesture mouse wheel back forward thread history navigation",
],
desktopOnly: true,
macOnly: true,
},
{
id: "text-generation-model",
title: "Text generation model",
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 → Navigation → 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
13 changes: 13 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,19 @@ describe("ClientSettings quit confirmation", () => {
});
});

describe("ClientSettings history swipe navigation", () => {
it.each([undefined, true, false])("decodes and round-trips %s", (historySwipeNavigation) => {
const input = historySwipeNavigation === undefined ? {} : { historySwipeNavigation };
const settings = decodeClientSettings(input);

expect(settings.historySwipeNavigation).toBe(historySwipeNavigation ?? true);
expect(decodeClientSettings(encodeClientSettings(settings)).historySwipeNavigation).toBe(
historySwipeNavigation ?? true,
);
expect(decodeClientSettingsPatch(input)).toEqual(input);
});
});

describe("provider enabled defaults", () => {
it("resolves instance enabled state with explicit false winning", () => {
const grok = ProviderDriverKind.make("grok");
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 @@ -498,6 +498,7 @@ export const ClientSettingsSchema = Schema.Struct({
fontFamilyTerminal: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))),

fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
historySwipeNavigation: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
// Older installs also decode to null; onboarding must additionally require an empty workspace.
onboardingCompletedAt: Schema.NullOr(Schema.String).pipe(
Schema.withDecodingDefault(Effect.succeed(null)),
Expand Down Expand Up @@ -2072,6 +2073,7 @@ export const ClientSettingsPatch = Schema.Struct({
fontFamilySans: Schema.optionalKey(FontFamilyPreference),
fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference),
fontSmoothing: Schema.optionalKey(Schema.Boolean),
historySwipeNavigation: Schema.optionalKey(Schema.Boolean),
persistComposerContextStrip: Schema.optionalKey(Schema.Boolean),
favorites: Schema.optionalKey(ModelFavorites),
providerModelPreferences: Schema.optionalKey(ProviderModelPreferences),
Expand Down
Loading