diff --git a/web-ui/src/components/runtime-settings-dialog.test.tsx b/web-ui/src/components/runtime-settings-dialog.test.tsx index 9b9d6b471..13e1b4166 100644 --- a/web-ui/src/components/runtime-settings-dialog.test.tsx +++ b/web-ui/src/components/runtime-settings-dialog.test.tsx @@ -166,6 +166,13 @@ function findButtonByAriaLabel(container: ParentNode, ariaLabel: string): HTMLBu ) ?? null) as HTMLButtonElement | null; } +function setScrollMetric(element: HTMLElement, property: "clientHeight" | "scrollHeight" | "scrollTop", value: number) { + Object.defineProperty(element, property, { + value, + configurable: true, + }); +} + const savedClineOauthConfig = { selectedAgentId: "cline", selectedShortcutLabel: null, @@ -282,6 +289,64 @@ describe("RuntimeSettingsDialog", () => { expect(resetLayoutCustomizationsMock).toHaveBeenCalledTimes(1); }); + it("activates the final settings nav item when the dialog is scrolled to the bottom", async () => { + await act(async () => { + root.render( + {}} + />, + ); + }); + + const projectMarker = document.querySelector('[data-settings-section="project"]'); + const scrollBody = projectMarker?.parentElement; + if (!projectMarker || !(scrollBody instanceof HTMLElement)) { + throw new Error("Expected the Project settings section to render inside a scrollable body."); + } + + setScrollMetric(scrollBody, "scrollHeight", 1000); + setScrollMetric(scrollBody, "clientHeight", 400); + setScrollMetric(scrollBody, "scrollTop", 600); + scrollBody.getBoundingClientRect = () => + ({ + top: 100, + bottom: 500, + left: 0, + right: 600, + width: 600, + height: 400, + x: 0, + y: 100, + toJSON: () => ({}), + }) satisfies DOMRect; + + for (const marker of Array.from(scrollBody.querySelectorAll("[data-settings-section]"))) { + const markerTop = marker === projectMarker ? 170 : 120; + marker.getBoundingClientRect = () => + ({ + top: markerTop, + bottom: markerTop, + left: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: markerTop, + toJSON: () => ({}), + }) satisfies DOMRect; + } + + await act(async () => { + scrollBody.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + + const projectNavButton = findButtonByText(document.body, "Project"); + expect(projectNavButton?.className).toContain("bg-surface-3"); + }); + it("enables save on theme change and reverts preview on cancel", async () => { const handleOpenChange = vi.fn(); await act(async () => { diff --git a/web-ui/src/components/runtime-settings-dialog.tsx b/web-ui/src/components/runtime-settings-dialog.tsx index 314e80c68..e393a4cb0 100644 --- a/web-ui/src/components/runtime-settings-dialog.tsx +++ b/web-ui/src/components/runtime-settings-dialog.tsx @@ -110,6 +110,8 @@ const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { id: "project", label: "Project", icon: }, ]; +const SETTINGS_SCROLL_BOTTOM_TOLERANCE_PX = 2; + function getShortcutIconOption(icon: string | undefined): RuntimeShortcutIconOption { return getRuntimeShortcutPickerOption(icon); } @@ -600,6 +602,7 @@ export function RuntimeSettingsDialog({ if (!body) return; const headings = body.querySelectorAll("[data-settings-section]"); const bodyRect = body.getBoundingClientRect(); + const maxScrollTop = Math.max(0, body.scrollHeight - body.clientHeight); let current: SettingsNavId = "general"; for (const heading of headings) { @@ -610,6 +613,12 @@ export function RuntimeSettingsDialog({ } } + if (maxScrollTop > 0 && body.scrollTop >= maxScrollTop - SETTINGS_SCROLL_BOTTOM_TOLERANCE_PX) { + const lastHeading = headings.item(headings.length - 1); + const id = lastHeading?.getAttribute("data-settings-section"); + if (id) current = id as SettingsNavId; + } + setActiveSection(current); }, []);