diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 6eb605e5..ca5ff91d 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -3436,6 +3436,9 @@ function openModal(content) { const m = $("#modal"), d = m.firstElementChild; const fresh = m.hidden || m.classList.contains("out"); const top = m.hidden ? 0 : d.querySelector(".ebody")?.scrollTop || 0; + // Names & levels scrolls inside the editor body, so keep its own position. + const names = !fresh && d.querySelector(".mnames:not([hidden])"); + const namesAt = names && { provider: names.dataset.provider, top: names.scrollTop }; m.classList.remove("out"); d.classList.remove("swap"); if (!fresh) { void d.offsetWidth; d.classList.add("swap"); } // content changed: a soft refresh, not a re-entrance @@ -3444,6 +3447,8 @@ function openModal(content) { m.hidden = false; const body = content.querySelector(":scope > .ebody"); if (body) body.scrollTop = top; // a re-render keeps the place + const nextNames = content.querySelector(".mnames:not([hidden])"); + if (namesAt && nextNames?.dataset.provider === namesAt.provider) nextNames.scrollTop = namesAt.top; if (!fresh) return; for (const a of [...m.getAnimations(), ...d.getAnimations()]) a.cancel(); d.style.opacity = d.style.transform = m.style.opacity = ""; @@ -4478,6 +4483,7 @@ function renderModels(p) { const box = el("div", "models"); const chips = el("div", "mchips"); const names = el("div", "mnames"); + names.dataset.provider = p.id; const q = p.models.length > 24 ? input("", t("filter {n} models…", { n: p.models.length })) : null; const draw = () => { if (agentMenu && chips.contains(agentMenu.anchor)) closeAgentMenu(); diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 71afa655..59a3832d 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -11,6 +11,9 @@ fixtures. It checks session folder/model filters and the main model picker in Chromium and WebKit, including wheel, scrollbar track/thumb, keyboard selection and dismissal. It does not start the backend or read local user configuration. +`provider-levels-scroll.test.cjs` keeps the provider editor's scrollable model +list in place after saving a reasoning level farther down the list. + `click-scroll.test.cjs` holds the rule that a click never moves the page (see "where the reader is" in `app.js`): on the Routing page, Live and a day picked in turn with the list scrolled to its end; a click whose handler sets @@ -510,7 +513,7 @@ WebKit. With Node.js and Playwright available: ```sh -node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-resets.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs +node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-resets.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs ``` If Playwright is installed outside the repository, set `NODE_PATH` to the diff --git a/internal/gui/tests/provider-levels-scroll.test.cjs b/internal/gui/tests/provider-levels-scroll.test.cjs new file mode 100644 index 00000000..6bc78703 --- /dev/null +++ b/internal/gui/tests/provider-levels-scroll.test.cjs @@ -0,0 +1,70 @@ +// A saved reasoning level redraws the provider editor without moving its +// scrollable model list back to the first model. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const levels = ["none", "low", "medium", "high"]; +const state = { agents: [], profiles: [], settings: { lang: "en", theme: "light" } }; + +function serve(posts) { + const models = Array.from({ length: 10 }, (_, i) => ({ id: `model-${i + 1}`, name: `Model ${i + 1}`, on: true, efforts: levels })); + const provider = { id: "relay", name: "Relay", icon: "generic", chat: "https://relay.example/v1", responses: "", anthropic: "", models, agents: [], key: { set: true, masked: "sk-…1234" }, ready: true }; + const providers = { providers: [provider], presets: [], excluded: [], gateway: { running: true, window: true } }; + return async (route) => { + const url = new URL(route.request().url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: 'window.bootPrefs = {lang:"en",theme:"light",web:true};' }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state); + if (url.pathname === "/api/providers") return json(providers); + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/provider/efforts") { + const body = route.request().postDataJSON(); + posts.push(body); + const model = models.find((m) => m.id === body.model); + model.kept = body.efforts.length ? body.efforts : undefined; + return json(providers); + } + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + await route.fulfill({ body: await fs.readFile(file), contentType: { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)] }); + }; +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(`${engine}: changing a model's reasoning level keeps the model list in place`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" })); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 900, height: 720 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + const errors = [], posts = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(posts)); + await page.goto("http://magpie.test/?view=providers"); + await page.locator(".row.provider").click(); + await page.getByRole("button", { name: "Names & levels" }).click(); + const list = page.locator(".mnames:not([hidden])"); + assert(await list.evaluate((e) => e.scrollHeight > e.clientHeight), "fixture must overflow"); + await list.evaluate((e) => { e.scrollTop = 260; }); + const before = await list.evaluate((e) => e.scrollTop); + assert(before > 0); + const fifth = page.locator(".mname", { has: page.locator("code", { hasText: "model-5" }) }); + const old = await list.elementHandle(); + await fifth.getByRole("checkbox", { name: "low" }).uncheck(); + await page.waitForFunction((e) => !e.isConnected, old); + assert.deepEqual(posts.at(-1), { id: "relay", model: "model-5", efforts: ["none", "medium", "high"] }); + assert.equal(await fifth.getByRole("checkbox", { name: "low" }).isChecked(), false); + assert.equal(await list.evaluate((e) => e.scrollTop), before); + const next = page.locator(".mname", { has: page.locator("code", { hasText: "model-6" }) }); + const current = await list.elementHandle(); + await next.getByRole("checkbox", { name: "high" }).uncheck(); + await page.waitForFunction((e) => !e.isConnected, current); + assert.deepEqual(posts.at(-1), { id: "relay", model: "model-6", efforts: ["none", "low", "medium"] }); + assert.equal(await list.evaluate((e) => e.scrollTop), before); + assert.deepEqual(errors, []); + }); +}