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:
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import { prettyModel } from "@/components/usage-format";
|
import { prettyModel } from "@/components/usage-format";
|
||||||
import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-menu";
|
import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-menu";
|
||||||
@@ -172,7 +172,13 @@ export function ModelControl() {
|
|||||||
setError(body.error ?? `settings rejected (${res.status})`);
|
setError(body.error ?? `settings rejected (${res.status})`);
|
||||||
return;
|
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);
|
setOpen(null);
|
||||||
notifySettingsSaved("models");
|
notifySettingsSaved("models");
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -118,6 +119,26 @@ export function SettingsDialogBody({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// MenuShell open-count — drives the content column's scroll lock.
|
// MenuShell open-count — drives the content column's scroll lock.
|
||||||
const [openMenus, setOpenMenus] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
const onOpen = () => setOpenMenus((n) => n + 1);
|
const onOpen = () => setOpenMenus((n) => n + 1);
|
||||||
const onClose = () => setOpenMenus((n) => Math.max(0, n - 1));
|
const onClose = () => setOpenMenus((n) => Math.max(0, n - 1));
|
||||||
@@ -176,8 +197,17 @@ export function SettingsDialogBody({
|
|||||||
setError(`${section}: ${detail.error ?? res.status}`);
|
setError(`${section}: ${detail.error ?? res.status}`);
|
||||||
return;
|
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(
|
setRecord(
|
||||||
(prev) => ({ ...(prev ?? {}), [section]: body }) as UserSettings,
|
(prev) =>
|
||||||
|
stored ?? ({ ...(prev ?? {}), [section]: body }) as UserSettings,
|
||||||
);
|
);
|
||||||
notifySettingsSaved(section);
|
notifySettingsSaved(section);
|
||||||
if (section === "appearance")
|
if (section === "appearance")
|
||||||
@@ -246,9 +276,12 @@ export function SettingsDialogBody({
|
|||||||
{/* Content column — one section at a time, this scrolls — and
|
{/* Content column — one section at a time, this scrolls — and
|
||||||
LOCKS while any MenuShell is open (owner ruling: no
|
LOCKS while any MenuShell is open (owner ruling: no
|
||||||
scrolling with a dropdown down; MenuShell announces
|
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
|
<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"
|
data-testid="settings-content"
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
Reference in New Issue
Block a user