From f80b1a9d2c4b790dcaddd013e368cc38a62e53e8 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 22 Sep 2026 22:45:11 +0300 Subject: [PATCH] Fix wave: scroll-lock without layout shift; every models write mirrors the stored record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1 (owner: "lock must not shift"): the settings dialog's scroll-lock no longer toggles the content column to overflow-hidden (which hid the scrollbar and shifted everything right). The column KEEPS overflow-y-auto — scrollbar and widths stay exactly as they are — and while any MenuShell is open, non-passive wheel + touchmove listeners on the column preventDefault so it cannot scroll (owner-ruled mechanism: the scrollbar may stay, scrolling must not happen, nothing may move). Native listeners via ref — React attaches synthetic wheel/touch passively at the root. Fix 2 (owner repro: xhigh pin survives a GLM swap on screen until reload): every models-writing surface now mirrors SERVER TRUTH, never the request body. The PUT already returns the full stored record with clamps applied — settings-dialog write() and the composer's save() now setRecord/setModels from the response, so a swap that rides an unsupported level shows the clamped value in the same interaction: the row trigger, the open menu's level list + checkmark, and the composer pill all read the stored record. Live-verified the owner's exact repro: deepseek pinned extra high → GLM swap via the slot menu → trigger/menu/pill showed the stored clamped level immediately; and the lock cycle: scroll 300 → blocked while open → 300 after release, zero positional shift at engage. --- components/model-picker.tsx | 10 +++++-- components/settings/settings-dialog.tsx | 39 +++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 5819ff9..96dac5a 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { ModelCatalogEntry, ModelCatalogResponse, ModelsSettings } from "@/src/core"; +import type { ModelCatalogEntry, ModelCatalogResponse, ModelsSettings, UserSettings } from "@/src/core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { prettyModel } from "@/components/usage-format"; import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-menu"; @@ -172,7 +172,13 @@ export function ModelControl() { setError(body.error ?? `settings rejected (${res.status})`); return; } - setModels(next); + // Server truth, never the request body (fix wave): a pick + // that rides an unsupported level lands CLAMPED in the record + // — the pill must show the stored value in this interaction. + const stored = ( + (await res.json().catch(() => null)) as { settings?: UserSettings } | null + )?.settings?.models; + setModels(stored ?? next); setOpen(null); notifySettingsSaved("models"); } catch { diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index 8ac1af3..4e7acc9 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -26,6 +26,7 @@ import { useCallback, useContext, useEffect, + useRef, useState, } from "react"; import { createPortal } from "react-dom"; @@ -118,6 +119,26 @@ export function SettingsDialogBody({ const [error, setError] = useState(null); // MenuShell open-count — drives the content column's scroll lock. const [openMenus, setOpenMenus] = useState(0); + const contentRef = useRef(null); + // Scroll-lock (fix wave, owner ruling: the scrollbar MAY stay, + // scrolling must NOT happen, nothing may move): the content column + // keeps overflow-y-auto — the scrollbar and every width stay exactly + // as they are — and while any MenuShell is open, non-passive wheel + + // touchmove listeners on the column preventDefault. React attaches + // synthetic wheel/touch passively at the root, so this needs the + // native-listener ref seam. + useEffect(() => { + const el = contentRef.current; + if (!el || openMenus === 0) return; + const block = (e: Event) => e.preventDefault(); + el.addEventListener("wheel", block, { passive: false }); + el.addEventListener("touchmove", block, { passive: false }); + return () => { + el.removeEventListener("wheel", block); + el.removeEventListener("touchmove", block); + }; + }, [openMenus]); + useEffect(() => { const onOpen = () => setOpenMenus((n) => n + 1); const onClose = () => setOpenMenus((n) => Math.max(0, n - 1)); @@ -176,8 +197,17 @@ export function SettingsDialogBody({ setError(`${section}: ${detail.error ?? res.status}`); return; } + // Server truth, never the request body (fix wave, owner repro): + // the models section clamps pinned thinking levels on write + // (bd68486) — a swap that rides an unsupported level lands + // CLAMPED in the record, and the writing surface must show the + // stored value in this same interaction, not the optimistic one. + const stored = ( + (await res.json().catch(() => null)) as { settings?: UserSettings } | null + )?.settings; setRecord( - (prev) => ({ ...(prev ?? {}), [section]: body }) as UserSettings, + (prev) => + stored ?? ({ ...(prev ?? {}), [section]: body }) as UserSettings, ); notifySettingsSaved(section); if (section === "appearance") @@ -246,9 +276,12 @@ export function SettingsDialogBody({ {/* Content column — one section at a time, this scrolls — and LOCKS while any MenuShell is open (owner ruling: no scrolling with a dropdown down; MenuShell announces - open/close, the count keeps overlap honest). */} + open/close, the count keeps overlap honest). The lock + intercepts wheel/touch instead of hiding the scrollbar — + nothing may shift when the lock engages. */}
0 ? "overflow-hidden" : "overflow-y-auto"}`} + ref={contentRef} + className="min-w-0 flex-1 overflow-y-auto" data-testid="settings-content" > {error && (