From 5e53bb78c2bd2865acbe029e9f9797db8f2c5509 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Oct 2026 13:53:01 +0700 Subject: [PATCH] fix(app): keep your place in the Settings sidebar (#7899) Keep sidebar scroll position for the Settings visit across app and host section navigation. Reject stale scroll callbacks from replaced sidebars and reset the offset when leaving Settings. Preserve the existing sibling routes and host-selection behavior. Consolidate the restoration work and broader regression coverage from the predecessor pull requests. Closes #1532. Co-authored-by: bingqilinweimaotai <111987281+bingqilinweimaotai@users.noreply.github.com> Co-authored-by: shampoo --- .../e2e/browser/settings-navigation.spec.ts | 101 ++++++++++++++++++ packages/app/e2e/support/helpers/settings.ts | 86 +++++++++++++++ packages/app/src/app/_layout.tsx | 2 +- .../navigation/settings-presentation/index.ts | 57 ++++++++++ .../model.test.ts | 42 ++++++-- .../model.ts | 24 ++++- .../navigation/settings-selection/index.ts | 32 ------ packages/app/src/screens/settings-screen.tsx | 14 ++- packages/desktop/e2e/browser-tabs.e2e.mjs | 32 +++++- .../desktop/e2e/settings-scroll.electron.mjs | 68 ++++++++++++ 10 files changed, 411 insertions(+), 47 deletions(-) create mode 100644 packages/app/src/navigation/settings-presentation/index.ts rename packages/app/src/navigation/{settings-selection => settings-presentation}/model.test.ts (69%) rename packages/app/src/navigation/{settings-selection => settings-presentation}/model.ts (69%) delete mode 100644 packages/app/src/navigation/settings-selection/index.ts create mode 100644 packages/desktop/e2e/settings-scroll.electron.mjs diff --git a/packages/app/e2e/browser/settings-navigation.spec.ts b/packages/app/e2e/browser/settings-navigation.spec.ts index 214a7881c1..4d7afbd4b9 100644 --- a/packages/app/e2e/browser/settings-navigation.spec.ts +++ b/packages/app/e2e/browser/settings-navigation.spec.ts @@ -10,6 +10,15 @@ import { gotoAppShell, openSettings } from "../support/helpers/app"; import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { closeCompactSettings, + scrollSettingsSidebarToEnd, + revealSettingsSidebarSection, + expectSettingsSidebarScrollSettled, + scrollSettingsSidebarUp, + changeSettingsInterfaceFontSize, + selectSettingsHostSectionsRapidly, + expectSettingsSidebarScrollOffset, + scrollSettingsDetailDown, + expectSettingsDetailScrollOffset, openSettingsSection, expectSettingsHeader, openAddHostFlow, @@ -54,6 +63,98 @@ async function openWorkspace( await expect(page.getByTestId("menu-button")).toBeVisible(); } +test.describe("Settings sidebar scroll position", () => { + test.use({ viewport: { width: 1000, height: 500 } }); + + test("clamps after resizing and starts a fresh visit at the top", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + await scrollSettingsSidebarToEnd(page); + await page.setViewportSize({ width: 1000, height: 1100 }); + await expectSettingsSidebarScrollOffset(page, 0); + await page.setViewportSize({ width: 1000, height: 500 }); + await openSettingsSection(page, "appearance"); + await expectSettingsSidebarScrollOffset(page, 0); + await scrollSettingsSidebarToEnd(page); + await clickSettingsBackToWorkspace(page); + await openSettings(page); + await expectSettingsSidebarScrollOffset(page, 0); + }); + + test("keeps the sidebar position through interface font changes", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + await openSettingsSection(page, "appearance"); + const offset = await scrollSettingsSidebarToEnd(page); + await changeSettingsInterfaceFontSize(page, 20); + await expectSettingsSidebarScrollOffset(page, offset); + await openSettingsHostSection(page, getServerId(), "providers"); + await expectSettingsSidebarScrollOffset(page, offset); + }); + + test("scrolls the sidebar and detail pane independently", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + const sidebarOffset = await scrollSettingsSidebarToEnd(page); + await openSettingsHostSection(page, getServerId(), "providers"); + await expect(page.getByTestId("host-page-providers-card")).toBeVisible(); + const detailOffset = await scrollSettingsDetailDown(page); + await expectSettingsSidebarScrollOffset(page, sidebarOffset); + + await scrollSettingsSidebarUp(page, 60); + await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60); + await expectSettingsDetailScrollOffset(page, detailOffset); + }); + + test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + + const scrollTop = await scrollSettingsSidebarToEnd(page); + + await selectSettingsHostSectionsRapidly(page); + await expectSettingsSidebarScrollOffset(page, scrollTop); + + await expectSettingsSidebarScrollSettled(page, scrollTop); + }); + + test("keeps the sidebar position when switching between app and host sections", async ({ + page, + }) => { + await gotoAppShell(page); + await openSettings(page); + + const hostScrollTop = await scrollSettingsSidebarToEnd(page); + await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport(); + + for (const section of [ + "providers", + "terminals", + "providers", + "terminals", + "providers", + ] as const) { + await openSettingsHostSection(page, getServerId(), section); + await expect(page.getByTestId("page-title")).toHaveText( + section === "providers" ? "Providers" : "Terminals", + ); + await expectSettingsSidebarScrollOffset(page, hostScrollTop); + } + + const sidebar = page.getByTestId("settings-sidebar"); + const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics"); + await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport(); + + await openSettingsSection(page, "about"); + await expectAboutContent(page); + await expectSettingsSidebarScrollOffset(page, appScrollTop); + + await openSettingsSection(page, "diagnostics"); + await expectDiagnosticsContent(page); + await expectSettingsSidebarScrollOffset(page, appScrollTop); + }); +}); + test.describe("Settings sidebar navigation", () => { test("opening Settings closes the composer model selector until it is opened again", async ({ page, diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index e68f554f34..69791f813a 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -57,6 +57,92 @@ export async function openSettingsSection(page: Page, section: SettingsSection): await expectAppRoute(page, buildSettingsSectionRoute(section)); } +export async function revealSettingsSidebarSection( + page: Page, + section: SettingsSection, +): Promise { + await page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: SECTION_LABELS[section], exact: true }) + .scrollIntoViewIfNeeded(); + const offset = await page + .getByTestId("settings-sidebar-scroll-body") + .evaluate((node) => node.scrollTop); + expect(offset).toBeGreaterThan(0); + return offset; +} + +export async function expectSettingsSidebarScrollSettled( + page: Page, + offset: number, +): Promise { + // RN Web emits its final scroll callback 100 ms after scrolling stops. + await page.waitForTimeout(200); + await expectSettingsSidebarScrollOffset(page, offset); +} + +export async function selectSettingsHostSectionsRapidly(page: Page): Promise { + for (let selection = 0; selection < 5; selection += 1) { + await page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: "Providers", exact: true }) + .click(); + await page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: "Terminals", exact: true }) + .click(); + await expectSettingsHeader(page, "Terminals"); + } +} + +export async function scrollSettingsSidebarUp(page: Page, pixels: number): Promise { + await page.getByTestId("settings-sidebar-scroll-body").hover(); + await page.mouse.wheel(0, -pixels); +} + +export async function changeSettingsInterfaceFontSize(page: Page, size: number): Promise { + const input = page.getByRole("textbox", { name: "Interface font size" }); + await input.fill(String(size)); + await input.press("Tab"); + await expect(input).toHaveValue(String(size)); +} + +export async function scrollSettingsSidebarToEnd(page: Page): Promise { + const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible'); + await scrollBody.hover(); + await page.mouse.wheel(0, 2000); + await expect + .poll(() => + scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop), + ) + .toBe(0); + const offset = await scrollBody.evaluate((node) => node.scrollTop); + expect(offset).toBeGreaterThan(0); + return offset; +} + +export async function expectSettingsSidebarScrollOffset(page: Page, offset: number): Promise { + const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible'); + await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset); +} + +export async function scrollSettingsDetailDown(page: Page): Promise { + const scrollBody = page.getByTestId("settings-detail-scroll-body"); + await expect + .poll(() => scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight)) + .toBeGreaterThan(0); + await scrollBody.hover(); + await page.mouse.wheel(0, 1000); + await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + const offset = await scrollBody.evaluate((node) => node.scrollTop); + return offset; +} + +export async function expectSettingsDetailScrollOffset(page: Page, offset: number): Promise { + const scrollBody = page.getByTestId("settings-detail-scroll-body"); + await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset); +} + export async function openSettingsHost(page: Page, serverId: string): Promise { // Host sections are now flat top-level rows under the Host group. Navigate by // clicking the Connections section row; the picker only matters when >1 host. diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index f6db461c09..083c6b2144 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -72,7 +72,7 @@ import { type StartupBlocker, } from "@/navigation/host-runtime-bootstrap"; import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; -import { useSettingsRouteEntry } from "@/navigation/settings-selection"; +import { useSettingsRouteEntry } from "@/navigation/settings-presentation"; import { ThemedStack } from "@/navigation/themed-stack"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { AgentNavigationListener } from "@/desktop/agent-navigation"; diff --git a/packages/app/src/navigation/settings-presentation/index.ts b/packages/app/src/navigation/settings-presentation/index.ts new file mode 100644 index 0000000000..cdf86b0d19 --- /dev/null +++ b/packages/app/src/navigation/settings-presentation/index.ts @@ -0,0 +1,57 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; +import { ScrollView, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native"; +import { usePathname, useRootNavigationState } from "expo-router"; +import { useStore } from "zustand"; +import { + useIsLastWorkspaceSelectionHydrated, + useLastWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; +import { createSettingsPresentation } from "./model"; + +const selection = createSettingsPresentation(); + +// Mount once above the sibling Settings routes so replacements and Back share +// one visit, and leaving Settings gives the next workspace entry a fresh default. +export function useSettingsRouteEntry(): void { + const pathname = usePathname(); + const navigationState = useRootNavigationState(); + const routeKey = navigationState?.routes[navigationState.index]?.key; + const workspace = useLastWorkspaceSelection(); + const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated(); + const workspaceServerId = workspace?.serverId ?? null; + useLayoutEffect(() => { + if (isWorkspaceHydrated && routeKey) { + selection.enterRoute({ key: routeKey, pathname }, workspaceServerId); + } + }, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]); +} + +export function useSettingsHostSelection(): string | null { + return useStore(selection.store, (state) => state.selectedServerId); +} + +export const selectSettingsHost = selection.selectHost; + +// Sibling routes remount the wide sidebar. Only the current mount owns its +// offset: RN Web can deliver its final scroll callback after unmount. +export function useSettingsSidebarScroll(enabled: boolean) { + const ref = useRef(null); + const owner = useRef | null>(null); + const restore = useCallback(() => { + if (owner.current) ref.current?.scrollTo({ y: owner.current.offset, animated: false }); + }, []); + useLayoutEffect(() => { + if (!enabled) return; + const sidebar = selection.attachSidebar(); + owner.current = sidebar; + restore(); + return () => { + sidebar.detach(); + owner.current = null; + }; + }, [enabled, restore]); + const onScroll = useCallback((event: NativeSyntheticEvent) => { + owner.current?.remember(event.nativeEvent.contentOffset.y); + }, []); + return { ref, onScroll, onContentSizeChange: restore, scrollEventThrottle: 16 }; +} diff --git a/packages/app/src/navigation/settings-selection/model.test.ts b/packages/app/src/navigation/settings-presentation/model.test.ts similarity index 69% rename from packages/app/src/navigation/settings-selection/model.test.ts rename to packages/app/src/navigation/settings-presentation/model.test.ts index 1ffbff8485..a8970b17b4 100644 --- a/packages/app/src/navigation/settings-selection/model.test.ts +++ b/packages/app/src/navigation/settings-presentation/model.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createSettingsSelection } from "./model"; +import { createSettingsPresentation } from "./model"; describe("Settings host selection", () => { it("keeps picker choices across sections, replacement and Back", () => { - const settings = createSettingsSelection(); + const settings = createSettingsPresentation(); settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); expect(settings.store.getState().selectedServerId).toBe("workspace-host"); settings.selectHost("chosen-host"); @@ -27,7 +27,7 @@ describe("Settings host selection", () => { }); it("applies explicit host links once, including project and plugin screens", () => { - const settings = createSettingsSelection(); + const settings = createSettingsPresentation(); settings.enterRoute( { key: "/settings/hosts/linked-host/projects/project", @@ -56,7 +56,7 @@ describe("Settings host selection", () => { }); it("seeds each new visit from the workspace, including a retained settings route", () => { - const settings = createSettingsSelection(); + const settings = createSettingsPresentation(); settings.enterRoute( { key: "/settings/general", pathname: "/settings/general" }, "first-workspace-host", @@ -77,7 +77,7 @@ describe("Settings host selection", () => { }); it("does not replay a retained host route after a newer picker choice", () => { - const settings = createSettingsSelection(); + const settings = createSettingsPresentation(); const oldRoute = { key: "old-host-screen", pathname: "/settings/hosts/first/host" }; settings.enterRoute(oldRoute, "workspace-host"); settings.selectHost("second"); @@ -93,10 +93,40 @@ describe("Settings host selection", () => { }); it("does not reseed a visit when remembered workspace state changes", () => { - const settings = createSettingsSelection(); + const settings = createSettingsPresentation(); settings.enterRoute({ key: "/settings", pathname: "/settings" }, null); settings.selectHost("chosen-host"); settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); expect(settings.store.getState().selectedServerId).toBe("chosen-host"); }); }); + +describe("Settings sidebar presentation", () => { + it("restores across replacements and rejects callbacks from older sidebars", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + const oldSidebar = settings.attachSidebar(); + oldSidebar.remember(246); + settings.enterRoute({ key: "host", pathname: "/settings/hosts/host/providers" }, "host"); + const currentSidebar = settings.attachSidebar(); + expect(currentSidebar.offset).toBe(246); + oldSidebar.remember(0); + oldSidebar.detach(); + expect(currentSidebar.offset).toBe(246); + currentSidebar.remember(190); + currentSidebar.detach(); + currentSidebar.remember(0); + expect(settings.attachSidebar().offset).toBe(190); + }); + + it("resets on leaving Settings and rejects callbacks from a previous visit", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + const previousVisit = settings.attachSidebar(); + previousVisit.remember(246); + settings.enterRoute({ key: "workspace", pathname: "/h/host/workspace/workspace" }, "host"); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + previousVisit.remember(246); + expect(settings.attachSidebar().offset).toBe(0); + }); +}); diff --git a/packages/app/src/navigation/settings-selection/model.ts b/packages/app/src/navigation/settings-presentation/model.ts similarity index 69% rename from packages/app/src/navigation/settings-selection/model.ts rename to packages/app/src/navigation/settings-presentation/model.ts index bedfb70626..2a88e9d82b 100644 --- a/packages/app/src/navigation/settings-selection/model.ts +++ b/packages/app/src/navigation/settings-presentation/model.ts @@ -9,10 +9,28 @@ interface SettingsSelectionState { selectedServerId: string | null; } -export function createSettingsSelection() { +export function createSettingsPresentation() { const store = createStore(() => ({ selectedServerId: null })); const enteredRoutes = new Map(); let inSettings = false; + let sidebarOffset = 0; + let sidebarOwner: object | null = null; + + function attachSidebar() { + const owner = {}; + sidebarOwner = owner; + return { + get offset() { + return sidebarOffset; + }, + remember(offset: number) { + if (sidebarOwner === owner) sidebarOffset = offset; + }, + detach() { + if (sidebarOwner === owner) sidebarOwner = null; + }, + }; + } function enterRoute( { key, pathname }: SettingsRouteEntry, @@ -20,6 +38,8 @@ export function createSettingsSelection() { ): void { const isSettings = pathname === "/settings" || pathname.startsWith("/settings/"); if (!isSettings) { + sidebarOffset = 0; + sidebarOwner = null; inSettings = false; enteredRoutes.clear(); return; @@ -42,5 +62,5 @@ export function createSettingsSelection() { store.setState({ selectedServerId: serverId }); } - return { store, enterRoute, selectHost }; + return { store, enterRoute, selectHost, attachSidebar }; } diff --git a/packages/app/src/navigation/settings-selection/index.ts b/packages/app/src/navigation/settings-selection/index.ts deleted file mode 100644 index dd4d57edd9..0000000000 --- a/packages/app/src/navigation/settings-selection/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useLayoutEffect } from "react"; -import { usePathname, useRootNavigationState } from "expo-router"; -import { useStore } from "zustand"; -import { - useIsLastWorkspaceSelectionHydrated, - useLastWorkspaceSelection, -} from "@/stores/navigation-active-workspace-store"; -import { createSettingsSelection } from "./model"; - -const selection = createSettingsSelection(); - -// Mount once above the sibling Settings routes so replacements and Back share -// one visit, and leaving Settings gives the next workspace entry a fresh default. -export function useSettingsRouteEntry(): void { - const pathname = usePathname(); - const navigationState = useRootNavigationState(); - const routeKey = navigationState?.routes[navigationState.index]?.key; - const workspace = useLastWorkspaceSelection(); - const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated(); - const workspaceServerId = workspace?.serverId ?? null; - useLayoutEffect(() => { - if (isWorkspaceHydrated && routeKey) { - selection.enterRoute({ key: routeKey, pathname }, workspaceServerId); - } - }, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]); -} - -export function useSettingsHostSelection(): string | null { - return useStore(selection.store, (state) => state.selectedServerId); -} - -export const selectSettingsHost = selection.selectHost; diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 8cfa720245..10d87cdcba 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -133,7 +133,11 @@ import { type HostSectionSlug, type SettingsSectionSlug, } from "@/utils/host-routes"; -import { selectSettingsHost, useSettingsHostSelection } from "@/navigation/settings-selection"; +import { + selectSettingsHost, + useSettingsHostSelection, + useSettingsSidebarScroll, +} from "@/navigation/settings-presentation"; import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation"; import { isWeb } from "@/constants/platform"; @@ -945,6 +949,7 @@ function SettingsSidebar({ const items = SIDEBAR_SECTION_ITEMS.filter((item) => isSectionAvailable(item, isDesktopApp)); const insets = useSafeAreaInsets(); const isDesktop = layout === "desktop"; + const sidebarScroll = useSettingsSidebarScroll(isDesktop); const outerContainerStyle = useMemo( () => [isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer], [isDesktop], @@ -1057,6 +1062,7 @@ function SettingsSidebar({ style={sidebarStyles.scrollBody} showsVerticalScrollIndicator={false} testID="settings-sidebar-scroll-body" + {...sidebarScroll} > {sidebarBody} @@ -1457,7 +1463,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti - + {content} diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index d403bfdfc3..78d2fa978e 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -14,6 +14,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { chromium } from "playwright"; import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs"; import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; +import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs"; import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs"; import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs"; @@ -455,17 +456,17 @@ function recordViewportMismatch(failures, label, actual, expected) { ); } -async function setWindowHidden(inspectorPort, hidden) { +async function evaluateDesktopWindow(inspectorPort, expression) { const [target] = await (await fetch(`http://127.0.0.1:${inspectorPort}/json/list`)).json(); const socket = new WebSocket(target.webSocketDebuggerUrl); try { - await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { socket.addEventListener("error", reject, { once: true }); socket.addEventListener("message", ({ data }) => { const response = JSON.parse(data); if (response.id !== 1) return; if (response.error || response.result?.exceptionDetails) reject(new Error(data)); - else resolve(); + else resolve(response.result.result.value); }); socket.addEventListener( "open", @@ -475,7 +476,8 @@ async function setWindowHidden(inspectorPort, hidden) { id: 1, method: "Runtime.evaluate", params: { - expression: `(() => { const win = process.mainModule.require('electron').BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('localhost:')); win.${hidden ? "hide" : "show"}(); if (win.isVisible() !== ${!hidden}) throw new Error('Window visibility did not change'); })()`, + expression: `(() => { const win = process.mainModule.require('electron').BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('localhost:')); ${expression} })()`, + returnByValue: true, }, }), ), @@ -487,6 +489,20 @@ async function setWindowHidden(inspectorPort, hidden) { } } +async function setWindowHidden(inspectorPort, hidden) { + await evaluateDesktopWindow( + inspectorPort, + `win.${hidden ? "hide" : "show"}(); if (win.isVisible() !== ${!hidden}) throw new Error('Window visibility did not change');`, + ); +} + +async function resizeDesktopWindow(inspectorPort, { width, height }) { + return evaluateDesktopWindow( + inspectorPort, + `const [width, height] = win.getContentSize(); win.setContentSize(${width}, ${height}); return { width, height };`, + ); +} + async function verifyHiddenBrowserScreenshots({ page, client, @@ -1208,6 +1224,14 @@ async function main() { console.log("Plugin external links and workspace browser passed."); return; } + const settingsScroll = await runSettingsScrollRegression(page, (size) => + resizeDesktopWindow(inspectorPort, size), + ); + if (process.env.PASEO_DESKTOP_SETTINGS_SCROLL_ONLY === "1") { + writeJson(path.join(artifactDir, "result.json"), { settingsScroll }); + console.log("Desktop Settings scroll regression passed."); + return; + } const settingsMemory = await runSettingsMemoryRegression(page); if (process.env.PASEO_DESKTOP_SETTINGS_MEMORY_ONLY === "1") { writeJson(path.join(artifactDir, "result.json"), { settingsMemory }); diff --git a/packages/desktop/e2e/settings-scroll.electron.mjs b/packages/desktop/e2e/settings-scroll.electron.mjs new file mode 100644 index 0000000000..e72656db02 --- /dev/null +++ b/packages/desktop/e2e/settings-scroll.electron.mjs @@ -0,0 +1,68 @@ +import { expect } from "@playwright/test"; + +export async function runSettingsScrollRegression(page, resizeWindow) { + // Device-metrics emulation also pins later webview guests. Resize the real + // window so the following browser regression starts without an override. + const original = await resizeWindow({ width: 1000, height: 500 }); + const sidebar = () => page.locator('[data-testid="settings-sidebar-scroll-body"]:visible'); + const offset = () => sidebar().evaluate((node) => node.scrollTop); + const observations = []; + try { + await expect + .poll(() => page.evaluate(() => ({ width: innerWidth, height: innerHeight }))) + .toEqual({ width: 1000, height: 500 }); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await sidebar().hover(); + await page.mouse.wheel(0, 2000); + await expect.poll(offset).toBeGreaterThan(0); + const saved = await offset(); + for (const title of ["Providers", "Terminals", "Providers", "Terminals"]) { + const button = page + .locator('[data-testid="settings-sidebar"]:visible') + .getByRole("button", { name: title, exact: true }); + await expect(button).toBeInViewport(); + await button.click(); + await expect(page.locator('[data-testid="settings-detail-header-title"]:visible')).toHaveText( + title, + ); + await expect.poll(offset).toBe(saved); + observations.push({ title, offset: await offset() }); + } + const detail = page.locator('[data-testid="settings-detail-scroll-body"]:visible'); + await page.getByRole("button", { name: "Providers", exact: true }).click(); + await detail.hover(); + await page.mouse.wheel(0, 1000); + await expect.poll(() => detail.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + await expect.poll(offset).toBe(saved); + observations.push({ + independentDetailOffset: await detail.evaluate((node) => node.scrollTop), + sidebarOffset: await offset(), + }); + await sidebar().hover(); + await page.mouse.wheel(0, -2000); + await expect.poll(offset).toBe(0); + await page.mouse.wheel(0, 60); + await expect.poll(offset).toBe(60); + for (const title of ["Sidebar", "Appearance", "Sidebar"]) { + const button = page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: title, exact: true }); + await expect(button).toBeInViewport(); + await button.click(); + await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title); + await expect.poll(offset).toBe(60); + observations.push({ title, offset: await offset() }); + } + await page.locator('[data-testid="settings-back-to-workspace"]:visible').click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect.poll(offset).toBe(0); + observations.push({ reentryOffset: await offset() }); + await page.locator('[data-testid="settings-back-to-workspace"]:visible').click(); + return observations; + } finally { + await resizeWindow(original); + await expect + .poll(() => page.evaluate(() => ({ width: innerWidth, height: innerHeight }))) + .toEqual(original); + } +}