Fix wave: scroll-lock without layout shift; every models write mirrors the stored record

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.
This commit is contained in:
amosblomqvist
2026-09-22 22:45:11 +03:00
parent 5e92d0e73e
commit f80b1a9d2c
2 changed files with 44 additions and 5 deletions
+8 -2
View File
@@ -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 {
+36 -3
View File
@@ -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<string | null>(null);
// MenuShell open-count — drives the content column's scroll lock.
const [openMenus, setOpenMenus] = useState(0);
const contentRef = useRef<HTMLDivElement | null>(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. */}
<div
className={`min-w-0 flex-1 ${openMenus > 0 ? "overflow-hidden" : "overflow-y-auto"}`}
ref={contentRef}
className="min-w-0 flex-1 overflow-y-auto"
data-testid="settings-content"
>
{error && (