diff --git a/apps/web/src/lib/settingsNavigation.test.ts b/apps/web/src/lib/settingsNavigation.test.ts new file mode 100644 index 0000000000..007d31bb92 --- /dev/null +++ b/apps/web/src/lib/settingsNavigation.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { canNavigateBackInApp, shouldCloseSettingsOnEscape } from "./settingsNavigation"; + +describe("shouldCloseSettingsOnEscape", () => { + it("matches plain Escape", () => { + expect(shouldCloseSettingsOnEscape({ key: "Escape" })).toBe(true); + }); + + it("ignores modified Escape shortcuts", () => { + expect(shouldCloseSettingsOnEscape({ key: "Escape", metaKey: true })).toBe(false); + expect(shouldCloseSettingsOnEscape({ key: "Escape", ctrlKey: true })).toBe(false); + expect(shouldCloseSettingsOnEscape({ key: "Escape", altKey: true })).toBe(false); + expect(shouldCloseSettingsOnEscape({ key: "Escape", shiftKey: true })).toBe(false); + }); + + it("ignores prevented and non-Escape events", () => { + expect(shouldCloseSettingsOnEscape({ key: "Escape", defaultPrevented: true })).toBe(false); + expect(shouldCloseSettingsOnEscape({ key: "Enter" })).toBe(false); + }); +}); + +describe("canNavigateBackInApp", () => { + it("returns true when tanstack router history has a previous entry", () => { + expect(canNavigateBackInApp({ __TSR_index: 1 })).toBe(true); + expect(canNavigateBackInApp({ __TSR_index: 4 })).toBe(true); + }); + + it("returns false for empty or external history state", () => { + expect(canNavigateBackInApp(null)).toBe(false); + expect(canNavigateBackInApp({})).toBe(false); + expect(canNavigateBackInApp({ __TSR_index: 0 })).toBe(false); + expect(canNavigateBackInApp({ __TSR_index: "1" })).toBe(false); + }); +}); diff --git a/apps/web/src/lib/settingsNavigation.ts b/apps/web/src/lib/settingsNavigation.ts new file mode 100644 index 0000000000..1f1a449e77 --- /dev/null +++ b/apps/web/src/lib/settingsNavigation.ts @@ -0,0 +1,28 @@ +export interface SettingsEscapeEventLike { + key: string; + defaultPrevented?: boolean; + metaKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; +} + +export function shouldCloseSettingsOnEscape(event: SettingsEscapeEventLike): boolean { + return ( + event.key === "Escape" && + event.defaultPrevented !== true && + event.metaKey !== true && + event.ctrlKey !== true && + event.altKey !== true && + event.shiftKey !== true + ); +} + +export function canNavigateBackInApp(historyState: unknown): boolean { + if (!historyState || typeof historyState !== "object") { + return false; + } + + const index = (historyState as { __TSR_index?: unknown }).__TSR_index; + return typeof index === "number" && index > 0; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 3e92891a54..04f2920c24 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDownIcon, @@ -44,6 +44,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip" import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; +import { canNavigateBackInApp, shouldCloseSettingsOnEscape } from "../lib/settingsNavigation"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; import { formatRelativeTime } from "../timestampFormat"; @@ -284,6 +285,7 @@ function SettingResetButton({ label, onClick }: { label: string; onClick: () => } function SettingsRouteView() { + const navigate = useNavigate(); const { theme, setTheme } = useTheme(); const settings = useSettings(); const { updateSettings, resetSettings } = useUpdateSettings(); @@ -316,6 +318,29 @@ function SettingsRouteView() { const queryClient = useQueryClient(); useRelativeTimeTick(); + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (!shouldCloseSettingsOnEscape(event)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (canNavigateBackInApp(window.history.state)) { + window.history.back(); + return; + } + + void navigate({ to: "/", replace: true }); + }; + + window.addEventListener("keydown", onWindowKeyDown); + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + }; + }, [navigate]); + const refreshProviders = useCallback(() => { if (refreshingRef.current) return; refreshingRef.current = true;