gui: a key typed into the routing group filter, a provider's keys filter or the plugins search goes in once, through an IME too (#1055: 1, 2 → 1122)

Each filter drew its row again from its own input event, and the redraw
took the focused field out of the page and put it back (the routing
header's replaceChildren; the keys filter's whole renderProviders, which
made a new field; the plugins page's draw, which made a new head). An IME
committing while the field was out wrote its text twice.

replaceKeeping (app.js) replaces a box's children but leaves a node
already there in place. The routing header redraws around its filter, the
keys filter redraws its list around the filter instead of the whole
Providers page, and the plugins page keeps one head and one search field
and redraws around them. Everything around each field is still drawn
again, so a language switch, Select, and the counts follow as before.

The cause was found by KuaaMU in #1056, which stopped the routing header
being rebuilt while its shape was the same. That kept a language switch
from reaching the header and left the two sibling filters; this carries
over the diagnosis and fixes all three by not moving the field at all.

Verified: internal/gui/tests/filter-ime.test.cjs types into each field
through an IME in Chromium (CDP Input.imeSetComposition, then
Input.insertText) and by key in WebKit, in en/zh/ja/de at 1100px and
440px: 48/48 pass. Against origin/main's assets 48/48 fail (routing:
'1122', field out 4 times; keys and plugins: field taken out). Reverting
only routing.js, only plugins.js, or only the keys filter's oninput fails
exactly that field's 16 tests. Full GUI suite (both engines): 3326/3350;
the 24 failures (codex-auto-reset, mobile-web, old-webkit,
profile-confirm, purpose-filter, routing-failed-open, session-identity,
text-size) fail the same on origin/main. go build -tags nogui and go test
./internal/gui pass.

Co-authored-by: KuaaMU <KuaaMU@users.noreply.github.com>
This commit is contained in:
yetoneandKuaaMU committed 2026-10-07 17:57:33 +08:00
1 parent 01aaa8e925
commit 07e246e515
5 files changed
+285 -51

No files matched your search

+23 -8
View File
@@ -114,6 +114,19 @@ function el(tag, cls, text) {
if (text !== undefined) e.textContent = text;
return e;
}
// replaceKeeping is parent.replaceChildren(...nodes) that leaves a node
// already in parent where it is, rather than taking it out and putting it
// back. A filter redrawn from its own input event is one: taken out of the
// page while an IME commits, it gets the text twice (#1055: 1, 2 → 1122).
function replaceKeeping(parent, nodes) {
const keep = new Set(nodes);
for (const n of [...parent.childNodes]) if (!keep.has(n)) n.remove();
let at = parent.firstChild;
for (const n of nodes) {
if (n === at) at = at.nextSibling;
else parent.insertBefore(n, at);
}
}
function svg(d, size = 12, stroke = 1.6) {
const s = document.createElementNS("http://www.w3.org/2000/svg", "svg");
s.setAttribute("viewBox", "0 0 16 16");
@@ -11755,11 +11768,16 @@ function keyFoldRows(p, fold) {
q.className = "keys-filter";
q.dataset.provider = p.id;
q.setAttribute("aria-label", q.placeholder);
// typing redraws the keys list around the filter, which stays in the
// page: taken out and put back, an IME's text lands twice (#1055)
q.oninput = () => {
keysOpen[p.id].q = q.value;
const at = q.selectionStart;
renderProviders();
focusKeyFilter(p.id, at);
const list = q.closest(".accts.keys"), fresh = renderKeyAccounts(p);
const fbar = fresh.querySelector(".keys-tools"), fq = fbar?.querySelector(".keys-filter");
if (!list || !fq) { renderProviders(); return; }
replaceKeeping(bar, [...fbar.childNodes].map((n) => n === fq ? q : n));
list.className = fresh.className;
replaceKeeping(list, [...fresh.childNodes].map((n) => n === fbar ? bar : n));
};
q.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Escape") { if (q.value) q.oninput(q.value = ""); else { delete keysOpen[p.id]; renderProviders(); } } };
const count = el("span", "hint keys-count", fold.matched.length === all.length ? t("{n} keys", { n: all.length }) : t("{n} of {m} keys", { n: fold.matched.length, m: all.length }));
@@ -11791,11 +11809,8 @@ function keyFoldRows(p, fold) {
else if (fold.matched.length > fold.shown.length) after.push(el("div", "hint keys-rest", t("{n} more — type to narrow them down", { n: fold.matched.length - fold.shown.length })));
return { before: [bar], after };
}
function focusKeyFilter(id, at) {
const q = document.querySelector(`.keys-filter[data-provider="${CSS.escape(id)}"]`);
if (!q) return;
q.focus({ preventScroll: true });
if (at != null) q.setSelectionRange(at, at);
function focusKeyFilter(id) {
document.querySelector(`.keys-filter[data-provider="${CSS.escape(id)}"]`)?.focus({ preventScroll: true });
}
// splitKeys is the keys in text pasted at once, as provider.SplitKeys
+33 -38
View File
@@ -101,13 +101,6 @@
for (const l of listings || []) if (npm[l.package]) l.npm = npm[l.package];
for (const e of mine?.plugins || []) if (onNPM(e.spec) && npm[name(e.spec)]?.version) e.latest = npm[name(e.spec)].version;
}
// redrawn as a part comes: the search field keeps its focus
function redraw() {
const q = page.querySelector(".pm-find input");
const typing = q && document.activeElement === q;
draw();
if (typing) page.querySelector(".pm-find input")?.focus({ preventScroll: true });
}
async function loadMine() {
try {
mine = await api("plugins");
@@ -162,8 +155,8 @@
async function load() {
if (!mine && !listings) draw();
const parts = [
loadMine().then(() => { redraw(); window.renderPluginDot?.(); }),
loadListings().then(redraw),
loadMine().then(() => { draw(); window.renderPluginDot?.(); }),
loadListings().then(draw),
loadTagged().then(drawBody),
providers ? null : loadProviders().then(drawBody, () => {}),
];
@@ -471,8 +464,31 @@
return box;
}
// the head and its search field are made once and drawn again in
// place: the field is never taken out of the page, or a key an IME
// commits while it is out lands twice (#1055)
const headBox = el("div", "lib-head pm-head");
const find = el("label", "pm-find");
const q = el("input");
q.type = "search";
q.spellcheck = false;
q.autocomplete = "off";
q.oninput = () => {
query = q.value;
if (tab !== "discover") { tab = "discover"; draw(); }
clearTimeout(searchTimer);
const s = query.trim();
if (s.length < 2) hits = null;
else {
hits = { q: s, loading: true };
searchTimer = setTimeout(() => searchNPM(s), 350);
}
drawBody();
};
q.onkeydown = (ev) => { ev.stopPropagation(); if (ev.key === "Escape" && q.value) { q.value = ""; q.oninput(); } };
find.append(glyph(SEARCH, 13, 1.6), q);
function head() {
const h = el("div", "lib-head pm-head");
const n = mine?.plugins?.length || 0;
const tabs = segs([["discover", t("Discover")], ["installed", t("Installed") + (n ? " · " + n : "")]], tab, (id) => {
tab = id;
@@ -480,32 +496,12 @@
drawBody();
});
tabs.classList.add("lib-tabs");
const find = el("label", "pm-find");
find.append(glyph(SEARCH, 13, 1.6));
const q = el("input");
q.type = "search";
q.placeholder = t("Search plugins and npm…");
q.value = query;
q.spellcheck = false;
q.autocomplete = "off";
if (q.value !== query) q.value = query;
q.setAttribute("aria-label", t("Search plugins"));
q.oninput = () => {
query = q.value;
if (tab !== "discover") { tab = "discover"; draw(); page.querySelector(".pm-find input")?.focus(); }
clearTimeout(searchTimer);
const s = query.trim();
if (s.length < 2) hits = null;
else {
hits = { q: s, loading: true };
searchTimer = setTimeout(() => searchNPM(s), 350);
}
drawBody();
};
q.onkeydown = (ev) => { ev.stopPropagation(); if (ev.key === "Escape" && q.value) { q.value = ""; q.oninput(); } };
find.append(q);
h.append(tabs, el("span", "grow"), mirrorSwitch(), find);
h.classList.toggle("stuck", page.scrollTop > 0);
return h;
replaceKeeping(headBox, [tabs, el("span", "grow"), mirrorSwitch(), find]);
headBox.classList.toggle("stuck", page.scrollTop > 0);
return headBox;
}
// 「国内镜像」: the list, npm's packages and answers, and Bun asked of
@@ -533,13 +529,13 @@
const r = await api("plugins/mirror", { on: !on });
mine.mirror = r.mirror;
// the list again, now from the mirror, and npm's answers with it
if (r.mirror) loadListings().then(() => { redraw(); askNPM(); });
if (r.mirror) loadListings().then(() => { draw(); askNPM(); });
} catch (e) {
mine.mirror = on;
status(t(e.message), "err");
}
mirrorSaving = false;
redraw();
draw();
};
return b;
}
@@ -559,9 +555,8 @@
let body = null;
function draw() {
const scroll = page.scrollTop;
page.replaceChildren(head());
body = el("div", "lib-body pm-body");
page.append(body);
replaceKeeping(page, [head(), body]);
drawBody();
page.scrollTop = scroll;
}
+5 -5
View File
@@ -2651,8 +2651,8 @@
// Discord: they could only be removed one at a time); null otherwise
let gSel = null;
// gQ: the groups filtered by name and by the models in them, as many
// as there may be (PAMI on Discord); kept across redraws, and focused
// again when one comes while typing
// as there may be (PAMI on Discord); kept across redraws, never taken
// out of the page while it is there
const gQ = el("input", "sess-filter rt-gfilter");
gQ.type = "search";
gQ.spellcheck = false;
@@ -2677,7 +2677,6 @@
newBtn.append(svg(PLUS, 11, 1.8), el("span", "", t("New group")));
newBtn.onclick = () => newGroup();
const head = [el("span", "label", t("Routing groups")), el("span", "grow"), el("span", "note", t("models agents pick as one"))];
const typing = document.activeElement === gQ, [a, b] = [gQ.selectionStart, gQ.selectionEnd];
if (all.length > 1 || gQ.value) {
gQ.placeholder = t("Filter groups and models");
gQ.setAttribute("aria-label", gQ.placeholder);
@@ -2696,8 +2695,9 @@
}
head.push(newBtn);
}
gHead.replaceChildren(...head);
if (typing && gQ.isConnected) { gQ.focus({ preventScroll: true }); try { gQ.setSelectionRange(a, b); } catch {} }
// the filter stays in the page as the rest of the row is drawn again,
// so the key being typed into it lands once (#1055)
replaceKeeping(gHead, head);
drawFound();
drawNames();
const rows = [];
+15
View File
@@ -2082,3 +2082,18 @@ unimportable entries, unreadable sources, stable fingerprints despite ID
collisions, changed configuration fingerprints, excluding unticked collision and
disabled Alma entries while preserving the add-key case, and keeping credentials
server-side.
## Filters typed through an IME
`filter-ime.test.cjs` covers #1055: a digit typed into the Routing page's
group filter went in twice (1, 2 → 1122), because the filter's own input
event drew its row again and took the field out of the page while an IME
committed. It types into the routing group filter, a provider's keys filter
and the plugins search, through an IME in Chromium (CDP's
`Input.imeSetComposition`, then `Input.insertText`) and by key in WebKit,
and checks one character a key, the focus, the caret, and that the field
never leaves the page. The rows around each still follow it: the groups and
keys it matches, the routing header in the other language after
`setLocale`, the plugins listings arriving while the reader types. In
English, Chinese, Japanese and German, at 1100px and 440px. `MAGPIE_FILTER_ASSETS` points it
at another assets folder, to see it fail on the old code.
+209
View File
@@ -0,0 +1,209 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// #1055: a digit typed into the Routing page's group filter went in twice
// (1, 2 → 1122). The filter's own input event drew its row again with
// replaceChildren, which took the focused field out of the page and put it
// back; an IME committing its text while the field was out wrote it twice.
// The provider editor's keys filter and the plugins search were made the
// same way. Each is typed into here, through an IME in Chromium (CDP's
// imeSetComposition, then insertText) and by key in both engines, and the
// field must keep one character a key, its focus and its caret, and never
// leave the page. The row around it still follows the filter and the
// language. In English, Chinese, Japanese and German, wide and at 440px.
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 = process.env.MAGPIE_FILTER_ASSETS || path.resolve(__dirname, "../assets");
const models = [
{ id: "a/one", name: "one", providerName: "A", icon: "generic" },
{ id: "a/two", name: "two", providerName: "A", icon: "generic" },
{ id: "a/m12", name: "m12", providerName: "A", icon: "generic" },
];
const groups = [
{ id: "g", name: "G", members: ["a/one"], off: [], routing: "order", ready: true, memberInfo: [{ id: "a/one", ready: true }] },
{ id: "o", name: "O", members: ["a/two"], off: [], routing: "order", ready: true, memberInfo: [{ id: "a/two", ready: true }] },
{ id: "v12", name: "v12", members: ["a/m12"], off: [], routing: "order", ready: true, memberInfo: [{ id: "a/m12", ready: true }] },
];
const N = 300;
const idOf = (i) => i.toString(16).padStart(10, "0");
const relay = () => ({
id: "relay", name: "Relay", icon: "generic", chat: "https://relay.example.com/v1", responses: "", anthropic: "", catalog: "",
models: [{ id: "big", name: "", on: true }], agents: [], fallback: [], headers: {},
key: { set: true, masked: "sk-…0000" }, balanceToken: { takes: false, set: false }, proxy: "",
keyList: Array.from({ length: N }, (_, i) => ({ id: idOf(i), name: "", masked: "sk-…" + String(i).padStart(4, "0"), on: true, active: i === 0 })),
});
const listings = [
{ package: "@magpie-community/opencode-zed-auth", name: "Zed", icon: "zed", providers: ["zed"], community: true,
summary: { en: "Your Zed plan.", zh: "Zed 订阅。" }, npm: { version: "0.1.0", weekly: 120 } },
{ package: "opencode-gemini-auth", name: "Gemini", icon: "gemini-color", providers: ["google"],
summary: { en: "Gemini with a Google account.", zh: "用 Google 账号使用 Gemini。" }, npm: { version: "1.4.0", weekly: 9100 } },
];
// serve answers the page from fixtures; held, when given, keeps the plugin
// listings back until it is resolved, as a slow npm mirror would
function serve(lang, held) {
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:"${lang}",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({ agents: [], profiles: [], settings: { lang, theme: "light" } });
if (url.pathname === "/api/gateway/trace") {
if (url.searchParams.get("wait")) return new Promise(() => {});
return json({ mine: true, now: new Date().toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
}
if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] });
if (url.pathname.startsWith("/api/groups")) return json({ models, pools: [], groups });
if (url.pathname === "/api/providers") return json({ providers: [relay()], presets: [], excluded: [], gateway: { running: true, window: true }, plugins: [] });
if (url.pathname === "/api/plugins") return json({ bun: true, bunVersion: "1.3.0", plugins: [{ spec: "opencode-gemini-auth@1.4.0", providers: ["Gemini"], version: "1.4.0" }] });
if (url.pathname === "/api/plugins/listings") {
if (held) await held;
return json({ listings });
}
if (url.pathname === "/api/plugins/search") return json({ hits: [] });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
// watch counts each time the field, or a box it sits in, is taken out of
// the page from now on
const watch = (page, sel) => page.evaluate((sel) => {
const q = document.querySelector(sel);
window.__field = q;
window.__out = 0;
new MutationObserver((recs) => {
for (const r of recs) for (const n of r.removedNodes) if (n === q || n.contains?.(q)) window.__out++;
}).observe(document.body, { childList: true, subtree: true });
}, sel);
const field = (page) => page.evaluate(() => {
const q = window.__field, now = document.activeElement;
return { out: window.__out, same: now === q, value: q.value, caret: [q.selectionStart, q.selectionEnd], connected: q.isConnected };
});
// ime types each character the way an input method commits it: a
// composition, then its text (Chromium only, through CDP)
async function ime(page, text) {
const c = await page.context().newCDPSession(page);
for (const ch of text) {
await c.send("Input.imeSetComposition", { text: ch, selectionStart: 1, selectionEnd: 1 });
await c.send("Input.insertText", { text: ch });
await page.waitForTimeout(30);
}
await c.detach();
}
// other: the language the routing header is switched to, and its words
const words = {
en: { label: "Routing groups", installed: "Installed", of: "1 of 300 keys", other: ["zh", "路由组", "筛选路由组和模型"] },
zh: { label: "路由组", installed: "已安装", of: "300 个密钥中的 1 个", other: ["en", "Routing groups", "Filter groups and models"] },
ja: { label: "ルーティンググループ", installed: "インストール済み", of: "300 個中 1 個のキー", other: ["en", "Routing groups", "Filter groups and models"] },
de: { label: "Weiterleitungsgruppen", installed: "Installiert", of: "1 von 300 Schlüsseln", other: ["en", "Routing groups", "Filter groups and models"] },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh", "ja", "de"]) {
for (const width of [1100, 440]) {
const w = words[lang];
const open = async (t, view, held) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const page = await (await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, held));
await page.goto("http://magpie.test/?view=" + view);
return { page, errors };
};
// typeIn: through an IME where there is one to drive, by key elsewhere
const typeIn = (page, text) => engine === "chromium" ? ime(page, text) : page.keyboard.type(text);
test(`${engine} ${lang} ${width}px: the routing group filter takes one character a key`, async (t) => {
const { page, errors } = await open(t, "routing");
const q = page.locator("input.rt-gfilter");
await q.waitFor();
await watch(page, "input.rt-gfilter");
await q.focus();
await typeIn(page, "12");
assert.deepEqual(await field(page), { out: 0, same: true, value: "12", caret: [2, 2], connected: true }, "the filter after 1, 2");
// the groups follow it: only v12 has a 12 in it
await page.waitForFunction(() => [...document.querySelectorAll(".rt-groups .rt-group")].map((r) => r.dataset.id).join() === "v12");
assert.equal(await page.locator(".rt-gsec .row-head").first().locator(".label").textContent(), w.label);
// a key in the middle goes where the caret is
await page.keyboard.press("ArrowLeft");
await typeIn(page, "5");
assert.deepEqual(await field(page), { out: 0, same: true, value: "152", caret: [2, 2], connected: true }, "the filter after ←, 5");
await page.keyboard.press("Backspace");
await page.keyboard.press("ArrowRight");
await page.keyboard.press("Backspace");
assert.deepEqual(await field(page), { out: 0, same: true, value: "1", caret: [1, 1], connected: true }, "the filter after two Backspaces");
// the row around it is still drawn again: in the other language
// its words change, and the filter stays where it was
await page.evaluate((l) => setLocale(l), w.other[0]);
await page.waitForFunction((s) => document.querySelector(".rt-gsec .row-head .label")?.textContent === s, w.other[1]);
assert.equal(await q.getAttribute("placeholder"), w.other[2]);
const f = await field(page);
assert.equal(f.out, 0, "switching the language took the filter out");
assert.equal(f.value, "1");
// Select takes the buttons beside it away and leaves the filter
await q.fill("");
await page.locator(".rt-gselect").click();
await page.locator(".rt-gsel").waitFor();
assert.equal(await page.locator(".rt-gsec .row-head .rt-gselect, .rt-gsec .row-head .rt-gnew").count(), 0);
assert.equal(await page.locator(".rt-gsec .row-head input.rt-gfilter").count(), 1);
assert.deepEqual(errors, []);
});
test(`${engine} ${lang} ${width}px: the provider keys filter takes one character a key`, async (t) => {
const { page, errors } = await open(t, "providers");
await page.locator(".row.provider", { hasText: "Relay" }).first().click();
await page.locator(".editor .keys-more").click();
const q = page.locator(".keys-tools .keys-filter");
await q.waitFor();
await watch(page, ".keys-tools .keys-filter");
await q.focus();
await typeIn(page, "12");
assert.deepEqual(await field(page), { out: 0, same: true, value: "12", caret: [2, 2], connected: true }, "the filter after 1, 2");
await typeIn(page, "9");
assert.deepEqual(await field(page), { out: 0, same: true, value: "129", caret: [3, 3], connected: true }, "the filter after 9");
// the keys, and the count beside the filter, follow it: only sk-…0129
await page.waitForFunction(() => document.querySelectorAll(".editor .accts .acc[data-account-id]").length === 1);
assert.equal((await page.locator(".keys-count").textContent()).trim(), w.of);
assert.equal(await page.locator(".keys-tools").count(), 1);
await page.keyboard.press("Escape");
assert.deepEqual(await field(page), { out: 0, same: true, value: "", caret: [0, 0], connected: true }, "Escape empties the filter");
await page.waitForFunction(() => document.querySelectorAll(".editor .accts .acc[data-account-id]").length === 50);
assert.deepEqual(errors, []);
});
test(`${engine} ${lang} ${width}px: the plugins search takes one character a key`, async (t) => {
let release;
const held = new Promise((r) => { release = r; });
const { page, errors } = await open(t, "plugins", held);
const q = page.locator(".pm-find input");
await q.waitFor();
// from Installed, the first key goes to Discover
await page.locator(".lib-tabs .opt", { hasText: w.installed }).click();
await watch(page, ".pm-find input");
await q.focus();
await typeIn(page, "1");
assert.deepEqual(await field(page), { out: 0, same: true, value: "1", caret: [1, 1], connected: true }, "the search after 1 from Installed");
// the listings come while the reader types
release();
await page.locator(".pm-sec").first().waitFor();
await typeIn(page, "2");
assert.deepEqual(await field(page), { out: 0, same: true, value: "12", caret: [2, 2], connected: true }, "the search after the listings came, and 2");
await page.keyboard.press("Escape");
assert.equal(await q.inputValue(), "");
assert.equal((await field(page)).out, 0);
assert.deepEqual(errors, []);
});
}
}
}