diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css
index 1640f5b7..86e4b9ed 100644
--- a/internal/gui/assets/app.css
+++ b/internal/gui/assets/app.css
@@ -269,15 +269,20 @@ body.window .top.inrow .actions { margin-left: 0; }
the icons in a row, the tabs at their size in a row of their own under
them, sliding sideways when they don't fit (they were squeezed into the
room between, too small to read or tap) */
-@media (max-width: 600px) {
- body.web .top { flex-wrap: wrap; height: auto; row-gap: 4px; padding: 6px 10px 8px 14px; }
- body.web .top .brand, body.web .top .actions { height: 30px; align-items: center; }
- body.web .top .seg {
- position: relative; left: auto; top: auto; transform: none; order: 3; flex: 1 0 100%; max-width: 100%;
- overflow-x: auto; overflow-y: hidden; scrollbar-width: none; overscroll-behavior-x: contain;
+@media (max-width: 760px), (pointer: coarse) and (max-width: 1024px) {
+ body.web .top {
+ height: auto; min-height: 48px; flex-wrap: wrap; gap: 4px 8px;
+ padding: calc(6px + env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) 8px max(12px, env(safe-area-inset-left));
}
- body.web .top .seg::-webkit-scrollbar { display: none; }
- body.web .top .seg button { flex: 1 0 auto; height: 30px; padding: 0 12px; font-size: 13px; }
+ body.web .top .brand, body.web .top .actions { min-height: 40px; align-items: center; }
+ body.web .top #nav {
+ position: relative; left: auto; top: auto; transform: none;
+ order: 3; flex: 0 0 100%; min-width: 0; max-width: 100%;
+ overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: none;
+ }
+ body.web .top #nav::-webkit-scrollbar { display: none; }
+ body.web .top #nav button { flex: none; height: 40px; padding: 0 14px; font-size: 14px; }
+ body.web .top .actions { gap: 4px; }
}
@keyframes update-in { from { opacity: 0; transform: translateY(-3px) scale(.94); } }
@keyframes spin { to { transform: rotate(360deg); } }
@@ -2013,12 +2018,6 @@ input.words.search-key, input.words.search-url { flex: 1 1 120px; width: auto; m
.row.pref.lan-container .sub { white-space: normal; }
.pop.proto-menu.lan-address-menu { width: min(320px, calc(100vw - 16px)); max-height: min(360px, 70vh); overflow-y: auto; }
.lan-address-menu .pm-name { font: 12px var(--code); }
-/* a phone: a control too wide for the row beside its name goes under it
- rather than cut the name to a few letters */
-@media (max-width: 600px) {
- body.web .row.pref:not(.lan-address-row) { flex-wrap: wrap; row-gap: 8px; }
- body.web .row.pref:not(.lan-address-row) .who { flex: 1 1 150px; }
-}
@media (max-width: 600px) {
.row.pref.lan-address-row { height: 76px; }
.lan-address-controls .segs { right: 12px; }
@@ -2133,6 +2132,75 @@ body.panel .modal { padding: 16px 12px; }
.skeleton { animation: none !important; }
}
+/* A browser on a phone: keep the desktop and tray's layout independent.
+ Tabs scroll on their own row, content keeps the available height, and
+ form controls are large enough to use without zooming the page. */
+@media (max-width: 760px), (pointer: coarse) and (max-width: 1024px) {
+ body.web { height: 100%; font-size: 14px; }
+ body.web .icon { width: 44px; height: 44px; }
+ body.web .update { min-height: 36px; }
+ body.web .view {
+ --view-left: max(12px, env(safe-area-inset-left));
+ --view-right: max(12px, env(safe-area-inset-right));
+ padding: 2px var(--view-right) 12px var(--view-left);
+ }
+ body.web .foot { padding-bottom: max(8px, env(safe-area-inset-bottom)); min-width: 0; }
+ body.web .foot .status { min-width: 0; overflow-wrap: anywhere; }
+ body.web .row-head { height: auto; min-height: 40px; flex-wrap: wrap; gap: 4px; }
+ body.web .text, body.web .field, body.web .segs .opt, body.web .copy { min-height: 44px; }
+ body.web .text { padding: 8px 10px; }
+ body.web .copy { min-width: 44px; }
+ body.web .fields { width: 100%; grid-template-columns: minmax(0, 1fr) minmax(0, 124px); }
+ body.web .row.agent { flex-wrap: wrap; }
+ body.web .row.agent > .fields { flex-basis: 100%; }
+ /* Extra controls must not keep the desktop's fixed grid width. The model
+ gets the full row; effort and squares wrap below it without clipping. */
+ body.web #agents .row.agent > .fields,
+ body.web #agents.extras .row.agent > .fields {
+ display: flex; flex-wrap: wrap; width: 100%; min-width: 0; margin-left: 0;
+ }
+ body.web #agents .fields > .field.main,
+ body.web #agents .fields > .field.solo { flex: 1 0 100%; }
+ body.web #agents .fields > .field.side { flex: 1 0 124px; }
+ body.web #agents .extras-cell { flex-wrap: wrap; max-width: 100%; }
+ body.web #agents .field.extra { width: 44px; min-width: 44px; }
+ body.web .row.pref { flex-wrap: wrap; gap: 8px; padding: 12px; }
+ body.web .row.pref > .who { flex-basis: 100%; }
+ body.web .row.pref .sub { white-space: normal; }
+ body.web .row.pref .segs { margin-left: 0; max-width: 100%; flex-wrap: wrap; }
+ body.web .proxy-segs { margin-left: 0; flex-wrap: wrap; justify-content: flex-start; }
+ body.web .usage-head { height: auto; min-height: 44px; flex-wrap: wrap; gap: 8px; }
+ body.web .usage-head > .segs { max-width: 100%; flex-wrap: wrap; }
+ body.web .usage-head > .grow, body.web .sess-tools > .grow { display: none; }
+ body.web .sess-filter { order: 1; flex: 1 1 100%; width: 100%; }
+ body.web .sess-pick { min-height: 44px; max-width: 100%; font-size: 14px; }
+ body.web .stats, body.web .stats.six { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ body.web .kpi small { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
+ body.web .row.stat { display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; gap: 6px 8px; }
+ body.web .row.stat .who { grid-column: 2; grid-row: 1; }
+ body.web .row.stat .who .sub { white-space: normal; }
+ body.web .row.stat .cost { grid-column: 3; grid-row: 1; }
+ body.web .row.stat .num { grid-column: 2 / 4; grid-row: 2; min-width: 0; align-items: flex-start; }
+ body.web .row.stat .num small { max-width: 100%; white-space: normal; }
+ body.web .row.stat .share { grid-column: 2 / 4; grid-row: 3; margin: 0; }
+ body.web .connect { grid-template-columns: minmax(0, 1fr); gap: 6px; }
+ body.web .connect label { text-align: left; margin-top: 6px; }
+ body.web .connect .segs { flex-wrap: wrap; }
+ body.web .connect .val { height: auto; min-height: 44px; }
+ body.web input:not([type="range"]):not([type="checkbox"]):not([type="radio"]),
+ body.web textarea, body.web select { font-size: 16px; min-height: 44px; }
+ body.web .modal { padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); }
+ body.web .modal .editor.framed > .ebody, body.web .editor { grid-template-columns: minmax(0, 1fr); gap: 6px; padding: 12px; }
+ body.web .editor label { text-align: left; padding-top: 8px; }
+ body.web .editor .segs { max-width: 100%; flex-wrap: wrap; }
+ body.web .editor .proxy-row { flex-wrap: wrap; }
+ body.web .editor .proxy-row input.proxy-url { flex-basis: 100%; }
+ body.web .editor .bar { flex-wrap: wrap; }
+ body.web .pop { max-width: calc(100vw - 16px); max-height: calc(100% - 16px); }
+ body.web .pop .picker-body { min-height: 0; }
+ body.web .pop #list li { min-height: 44px; }
+}
+
/* Import from other apps: a checklist per app */
.editor.importapps { grid-template-columns: minmax(0, 1fr); }
.editor.importapps .appnote { font-size: 12px; color: var(--muted); line-height: 1.5; }
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index 06b7ef38..45fe384d 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -11,7 +11,7 @@ if (web) document.body.classList.add("web");
// iOS zooms the page into a field it focuses whose text is under 16px, and
// leaves it zoomed; at most 1 stops that, and Safari still lets a pinch zoom
if (web && (/iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1))) {
- document.querySelector('meta[name="viewport"]')?.setAttribute("content", "width=device-width, initial-scale=1, maximum-scale=1");
+ document.querySelector('meta[name="viewport"]')?.setAttribute("content", "width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover");
}
// The Mac window draws its title bar inside the page (the traffic lights);
// on Linux the page's header is the whole title bar (plainTitlebar), so it
@@ -1841,6 +1841,8 @@ function score(q, o) {
function placePop(anchor, w, h) {
const pop = $("#pop");
const r = anchor.getBoundingClientRect(), pad = 8;
+ w = Math.min(w, innerWidth - pad * 2);
+ h = Math.min(h, innerHeight - pad * 2);
pop.style.width = w + "px";
let x = Math.max(pad, Math.min(r.left, innerWidth - w - pad));
let y = r.bottom + 5;
@@ -1930,7 +1932,9 @@ function openPicker(agent, field, anchor, ev, only) {
q.value = "";
q.placeholder = modelPicker && extra(field) ? t("{field} — filter, or type any model id…", { field: t(field.label) }) : modelPicker ? t("Filter, or type any model id…") : t("Filter {field}…", { field: t(field.label) });
filter();
- q.focus();
+ // On touch screens leave the keyboard closed until the filter is tapped,
+ // so opening a model list leaves room to browse its choices.
+ if (!web || !matchMedia("(pointer: coarse)").matches) q.focus();
}
function effortName(option) {
@@ -8448,6 +8452,28 @@ function quotaWindows(sub) {
return windows;
}
+// Keep the last date and as many earlier dates as fit, without clipping their
+// text. Visibility keeps each label aligned to its bar; resizing restores them.
+function fitChartLabels() {
+ const narrow = web && matchMedia("(max-width: 760px), (pointer: coarse) and (max-width: 1024px)").matches;
+ for (const labels of document.querySelectorAll(".chart .labels")) {
+ const spans = [...labels.children];
+ for (const span of spans) span.style.visibility = "";
+ if (!narrow || !labels.offsetWidth) continue;
+ const box = labels.getBoundingClientRect();
+ let right = box.right + 8;
+ for (const span of spans.reverse()) {
+ if (!span.textContent) continue;
+ const range = document.createRange();
+ range.selectNodeContents(span);
+ const r = range.getBoundingClientRect();
+ if (r.right + 8 > right + 1 || r.left < box.left - 1) span.style.visibility = "hidden";
+ else right = r.left;
+ }
+ }
+}
+new ResizeObserver(fitChartLabels).observe($("#view-usage"));
+
function renderUsage() {
const u = usage;
const view = $("#view-usage");
@@ -8518,6 +8544,7 @@ function renderUsage() {
labels.append(el("span", "", i % every === 0 || last ? p.label : ""));
});
chart.append(el("div", "peak", fmtN(peak)), bars, labels);
+ fitChartLabels();
const total = Math.max(1, tokensOf(u));
const list = (id, groups) => {
@@ -10152,6 +10179,7 @@ function renderSessChart(chart, st, used, ov, acts) {
labels.append(el("span", "", i % every === 0 || end ? label : ""));
});
chart.append(bars, labels);
+ fitChartLabels();
function drawHead() {
const head = el("div", "sess-chart-head");
@@ -12347,7 +12375,7 @@ setTimeout(wag, 250);
// magpie web on a phone, or a browser as narrow: the header is two rows,
// the magpie and the icons over the tabs, which scroll sideways when they
// don't fit, the one open kept in sight
-function phoneWeb() { return document.body.classList.contains("web") && matchMedia("(max-width: 600px)").matches; }
+function phoneWeb() { return document.body.classList.contains("web") && matchMedia("(max-width: 760px), (pointer: coarse) and (max-width: 1024px)").matches; }
function navInSight() {
const nav = $("#nav"), on = nav?.querySelector("button.on");
if (!phoneWeb() || !on || nav.scrollWidth <= nav.clientWidth) return;
@@ -12355,7 +12383,7 @@ function navInSight() {
if (nav.scrollLeft > l) nav.scrollLeft = l;
else if (nav.scrollLeft < r) nav.scrollLeft = r;
}
-matchMedia("(max-width: 600px)").addEventListener?.("change", () => { fitTop(); navInSight(); });
+matchMedia("(max-width: 760px), (pointer: coarse) and (max-width: 1024px)").addEventListener?.("change", () => { fitTop(); navInSight(); });
// A narrow window has no room for the whole header: the name goes, leaving
// the magpie, and Update becomes its arrow; narrower still, the tabs stop
diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html
index b743c994..f8c4c9d7 100644
--- a/internal/gui/assets/index.html
+++ b/internal/gui/assets/index.html
@@ -2,7 +2,7 @@
-
+
magpie
diff --git a/internal/gui/assets/library.css b/internal/gui/assets/library.css
index 8aab4505..36fd3fd4 100644
--- a/internal/gui/assets/library.css
+++ b/internal/gui/assets/library.css
@@ -12,6 +12,15 @@
}
/* the window's view is padded 2px at the top: the strip reaches over it */
body.window .lib-head { top: -2px; margin: -2px -16px 0; padding: 8px 16px 4px; }
+@media (max-width: 760px), (pointer: coarse) and (max-width: 1024px) {
+ body.web .lib-head {
+ flex-wrap: wrap;
+ margin: -2px calc(-1 * var(--view-right)) 0 calc(-1 * var(--view-left));
+ padding: 8px var(--view-right) 4px var(--view-left);
+ }
+ body.web .lib-tabs { max-width: 100%; flex-wrap: wrap; }
+ body.web .lib-more { flex: none; }
+}
.lib-head.stuck { box-shadow: 0 1px 0 var(--line-2); }
.lib-tabs .opt { height: 24px; padding: 0 12px; font-size: 12.5px; }
.lib-more { display: inline-flex; align-items: center; gap: 5px; }
diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css
index 30fdffa4..35439911 100644
--- a/internal/gui/assets/routing.css
+++ b/internal/gui/assets/routing.css
@@ -299,7 +299,33 @@ button.rt-session { cursor: pointer; }
.rt-pool .ctl { display: grid; grid-template-columns: auto auto; align-items: center; justify-items: start; gap: 6px 8px; }
.rt-pool .lab { display: contents; }
.rt-pool .lab small { font-size: 11px; color: var(--muted); }
-@media (max-width: 560px) { .rt-group .tags { display: none; } }
+@media (max-width: 560px) { body:not(.web) .rt-group .tags { display: none; } }
+@media (max-width: 760px), (pointer: coarse) and (max-width: 1024px) {
+ body.web .rt-group {
+ display: grid; grid-template-columns: 20px minmax(0, 1fr) auto;
+ gap: 6px 8px; padding: 12px;
+ }
+ body.web .rt-group .tags { display: flex; flex-wrap: wrap; grid-column: 2 / -1; grid-row: 2; }
+ body.web .rt-group > .text { grid-column: 3; grid-row: 1; }
+ body.web .rt-group .nm { flex-wrap: wrap; }
+ body.web .rt-group .mem { white-space: normal; overflow-wrap: anywhere; }
+ body.web .rt-pick { height: auto; min-height: 44px; padding: 8px; font-size: 12.5px; }
+ body.web .rt-pick { flex-wrap: wrap; }
+ body.web .rt-gedit { grid-template-columns: minmax(0, 1fr); gap: 6px; padding: 12px; }
+ body.web .rt-gedit label { text-align: left; padding-top: 8px; }
+ body.web .rt-gedit .segs { flex-wrap: wrap; }
+ body.web .rt-gedit .fbrow { flex-wrap: wrap; }
+ body.web .rt-gedit .fbrow .n { flex: 1 1 calc(100% - 60px); white-space: normal; overflow-wrap: anywhere; }
+ body.web .rt-gedit .fbrow > .grow { display: none; }
+ body.web .rt-gedit .fbrow .text { min-width: 44px; }
+ body.web .rt-cond { height: auto; min-height: 44px; }
+ body.web .rt-gadd { min-height: 44px; }
+ body.web .rt-pool .ctl { grid-template-columns: minmax(0, 1fr); }
+ body.web .rt-pool .lab small { padding-top: 6px; }
+ body.web .rt-pool .segs { flex-wrap: wrap; }
+ body.web .rt-stats { flex-wrap: wrap; gap: 10px 16px; }
+ body.web #view-routing > .row-head .note { flex: 1 1 100%; order: 1; margin: 0; }
+}
.rt-gsec .segs .opt { white-space: nowrap; }
@container (max-width: 520px) {
.rt-gsec .row-head .note { display: none; }
diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md
index 29408698..ac95557b 100644
--- a/internal/gui/tests/README.md
+++ b/internal/gui/tests/README.md
@@ -1351,3 +1351,32 @@ Install and the installed plugin's "v… out". It runs in Chromium and
WebKit, in English and Chinese:
node --test internal/gui/tests/plugin-progressive.test.cjs
```
+
+## Mobile Web
+
+`mobile-web.test.cjs` checks all eight web pages and their navigation in
+Chromium and WebKit, English and Chinese, at touch widths 360/390/430/820.
+Agent fixtures include model/effort controls, Codex's subagent/sign-in squares
+and omp's three role squares. Every phone field must stay inside its row, model
+names must be readable, and each picker must open; desktop comparisons use
+those same populated rows.
+It checks that Library's folder button is reachable, Usage tokens, costs and
+stat explanations are readable, and chart dates do not overlap. A released
+touch's continuous scroll can outlast the input window, while scripts cannot
+move an idle page, including after momentum stops. This uses Settings → Allowances
+at 390×600, where the real settings rows leave room to scroll. The API is faked;
+no user configuration is read or changed.
+
+At desktop widths 900/1280, screenshots are compared with the same pages from
+`origin/main`. Dimensions must match; a pixel counts as different only when
+any RGBA channel differs by more than 48, and more than 300 such pixels fails.
+This tolerates Chromium's small antialiasing differences while still detecting
+layout changes. Fetch that branch before running; set `BASE_REF` to another
+local Git ref (`HEAD` for a self-comparison) to select a particular baseline.
+PNG decoding uses the copy bundled with Playwright, without another dependency.
+The empty allowance fixture completes after usage totals render, so both pages
+compare the same completed API state rather than a loading-order difference.
+
+```sh
+node --test internal/gui/tests/mobile-web.test.cjs internal/gui/tests/old-webkit.test.cjs
+```
diff --git a/internal/gui/tests/mobile-web.test.cjs b/internal/gui/tests/mobile-web.test.cjs
new file mode 100644
index 00000000..6ef5896b
--- /dev/null
+++ b/internal/gui/tests/mobile-web.test.cjs
@@ -0,0 +1,286 @@
+// Run with Node's test runner and Playwright on the module path; see README.md.
+// Phone navigation and content stay usable in both languages (#391). The
+// desktop screenshots match BASE_REF within a small rendering tolerance
+// (origin/main by default); only API
+// boundaries are faked, never the page's layout or scrolling helpers.
+const assert = require("node:assert/strict");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { execFileSync } = require("node:child_process");
+const { test } = require("node:test");
+const { chromium, webkit } = require("playwright");
+const { PNG } = require("playwright-core/lib/utilsBundle");
+
+const assets = path.resolve(__dirname, "../assets");
+const base = process.env.BASE_REF || "origin/main";
+const baseline = new Map();
+const at = "2026-09-30T12:00:00.000Z";
+const groups = [{ name: "Claude Code", icon: "claudecode-color", calls: 123, errors: 2,
+ input: 123456, output: 67890, cache_read: 543210, cache_write: 1234, reasoning: 12345, cost: 12.34, unpriced: 0 }];
+const usage = { ...groups[0], path: "/test/usage.jsonl", bucket: "day", agents: groups,
+ models: [{ ...groups[0], name: "a-model-with-a-long-name" }],
+ series: Array.from({ length: 30 }, (_, i) => ({ ...groups[0], label: "09-" + String(i + 1).padStart(2, "0") })) };
+// Populated rows are essential: an empty Agents list cannot expose clipped
+// model/effort controls or make the desktop comparison cover those rows.
+const modelOptions = [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color" },
+ { value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai" }];
+const modelField = (key, label, value = "") => ({ key, label, value, options: modelOptions });
+const agentRows = [
+ { id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", fields: [
+ modelField("model", "model", "claude-sonnet-4.5"),
+ { key: "effort", label: "thinking", value: "high", options: [{ value: "high", label: "High" }] },
+ ] },
+ { id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color", fields: [
+ modelField("model", "model", "gpt-6.1-sol"),
+ { key: "effort", label: "effort", value: "high", options: [{ value: "high", label: "High" }] },
+ modelField("subagent", "subagents"),
+ { key: "signin", label: "sign-in", value: "magpie", options: [{ value: "magpie", label: "magpie" }, { value: "chatgpt", label: "ChatGPT" }] },
+ ] },
+ { id: "omp", name: "omp", path: "/test/omp.json", icon: "omp", fields: [
+ modelField("model", "model", "gpt-6.1-sol"), modelField("subagent", "subagents"),
+ modelField("small", "smol"), modelField("slow", "slow"),
+ ] },
+];
+const views = ["agents", "providers", "gateway", "routing", "usage", "library", "plugins", "settings"];
+
+function server(lang, original = false, opened = []) {
+ 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: agentRows, profiles: [], settings: { lang, theme: "light" } });
+ if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true, calls: [], groups: [] } });
+ if (url.pathname === "/api/groups") return json({ groups: [], pools: [], models: [] });
+ if (url.pathname === "/api/usage/quotas") {
+ // main's usage render can reveal the empty quota heading again if it
+ // finishes last. Deliver the empty allowances after the totals paint,
+ // so both screenshot pages compare the same completed API state.
+ await route.request().frame().page().locator("#usageAgents .row.stat").first().waitFor({ state: "attached", timeout: 30000 });
+ return json([]);
+ }
+ if (url.pathname === "/api/usage") return json(usage);
+ if (url.pathname === "/api/gateway/trace") return json({ mine: true, now: at, seq: 0, routes: [], totals: { requests: 0, rerouted: 0, errors: 0 } });
+ if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] });
+ if (url.pathname === "/api/library") return json({ dir: "/test/library", home: "/test", agents: [], instructions: { agents: [], sets: [] }, servers: [], skills: [], projects: [], foundServers: [], foundSkills: [] });
+ if (url.pathname === "/api/plugins") return json({ bun: true, plugins: [], movable: [] });
+ if (url.pathname === "/api/plugins/listings") return json({ listings: [] });
+ if (url.pathname === "/api/plugins/npm") return json({ npm: {} });
+ if (url.pathname === "/api/plugins/market") return json({ listings: [], state: { bun: true, plugins: [] } });
+ if (url.pathname === "/api/library/reveal") { opened.push(route.request().postDataJSON()); return json({}); }
+ if (url.pathname.startsWith("/api/")) return json({});
+ const file = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
+ const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
+ try {
+ let body;
+ if (original) {
+ if (!baseline.has(file)) baseline.set(file, execFileSync("git", ["show", `${base}:internal/gui/assets/${file}`], { cwd: assets, maxBuffer: 4 * 1024 * 1024 }));
+ body = baseline.get(file);
+ } else body = await fs.readFile(path.join(assets, file));
+ await route.fulfill({ body, contentType });
+ } catch { await route.fulfill({ status: 404 }); }
+ };
+}
+
+async function open(browser, lang, width, { original = false, touch = true, opened = [] } = {}) {
+ const page = await browser.newPage({ viewport: { width, height: 900 }, hasTouch: touch, reducedMotion: "reduce", colorScheme: "light" });
+ page.setDefaultTimeout(5000);
+ await page.addInitScript(at => {
+ const RealDate = Date;
+ window.Date = class extends RealDate {
+ constructor(...args) { super(...(args.length ? args : [at])); }
+ static now() { return new RealDate(at).getTime(); }
+ };
+ }, at);
+ await page.route("http://magpie.test/**", server(lang, original, opened));
+ await page.goto("http://magpie.test/");
+ await page.locator("#nav button").first().waitFor();
+ return page;
+}
+
+async function go(page, view) {
+ // Playwright scrolls each real navigation button into sight before clicking;
+ // this exercises the phone's horizontal navigation strip too.
+ await page.locator(view === "settings" ? "#prefs" : `#nav [data-view="${view}"]`).click();
+ await page.locator(`#view-${view}`).waitFor();
+ if (view === "library") await page.locator("#view-library .lib-more:not(:disabled)").waitFor();
+ if (view === "plugins") await page.locator("#view-plugins .pm-find").waitFor();
+ if (view === "usage") {
+ await page.locator("#usageAgents .row.stat").waitFor();
+ await page.locator("#quotaHead").waitFor({ state: "hidden" });
+ }
+ await page.evaluate(() => document.fonts.ready);
+ await page.waitForTimeout(800); // let the tab's spring and content entrance settle
+}
+
+async function save(page, name) {
+ if (!process.env.ARTIFACT_DIR) return;
+ await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
+ await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, name + ".png"), animations: "disabled" });
+}
+
+async function fits(page, view) {
+ const dimensions = await page.locator(`#view-${view}`).evaluate(v => ({
+ view: [v.scrollWidth, v.clientWidth], body: [document.body.scrollWidth, innerWidth],
+ document: [document.documentElement.scrollWidth, innerWidth],
+ }));
+ for (const [name, [actual, available]] of Object.entries(dimensions)) assert(actual <= available + 1, `${view}: ${name} scrolls sideways (${actual} > ${available})`);
+}
+
+async function agentsFit(page) {
+ for (const agent of agentRows) {
+ const row = page.locator(`.row.agent[data-id="${agent.id}"]`);
+ await row.waitFor();
+ const bad = await row.evaluate(row => {
+ const box = row.getBoundingClientRect(), bad = [];
+ for (const field of row.querySelectorAll(".field")) {
+ const b = field.getBoundingClientRect();
+ if (b.left < box.left - 1 || b.right > box.right + 1 || b.top < box.top - 1 || b.bottom > box.bottom + 1) bad.push("field cropped: " + field.dataset.key);
+ }
+ const model = row.querySelector('.field[data-key="model"] .v');
+ if (!model?.textContent || model.scrollWidth > model.clientWidth + 1) bad.push("model name cropped");
+ return bad;
+ });
+ assert.deepEqual(bad, [], agent.name + ": every control and model name must be visible");
+ for (const field of agent.fields) {
+ await row.locator(`.field[data-key="${field.key}"]`).click();
+ await page.locator("#pop").waitFor();
+ assert.equal(await page.locator("#pop").isVisible(), true, agent.name + ": " + field.key + " opens");
+ await page.locator('#nav [data-view="agents"]').click();
+ await page.locator("#pop").waitFor({ state: "hidden" });
+ }
+ }
+}
+
+async function usageFits(page) {
+ const bad = await page.evaluate(() => {
+ const bad = [];
+ for (const r of document.querySelectorAll(".row.stat")) {
+ const box = r.getBoundingClientRect();
+ for (const e of r.querySelectorAll(".num, .cost")) {
+ const b = e.getBoundingClientRect();
+ if (b.left < box.left || b.right > box.right + 1 || e.scrollWidth > e.clientWidth + 1) bad.push("tokens or cost cropped: " + e.textContent);
+ }
+ }
+ for (const e of document.querySelectorAll("#stats small")) if (e.scrollWidth > e.clientWidth + 1) bad.push("stat explanation cropped: " + e.textContent);
+ let last;
+ const dates = [];
+ for (const e of document.querySelectorAll("#chart .labels span")) {
+ if (!e.textContent || getComputedStyle(e).visibility === "hidden") continue;
+ dates.push(e.textContent);
+ const text = document.createRange(); text.selectNodeContents(e);
+ const b = text.getBoundingClientRect();
+ if (last && b.left < last.right + 2) bad.push("date labels overlap");
+ const box = e.parentElement.getBoundingClientRect();
+ if (b.left < box.left - 1 || b.right > box.right + 1) bad.push("date label outside chart");
+ last = b;
+ }
+ if (dates.length < 3 || dates[0] !== "09-01" || dates[dates.length - 1] !== "09-30") bad.push("chart must retain its endpoints and intermediate dates");
+ return bad;
+ });
+ assert.deepEqual(bad, []);
+ for (const selector of ["#usageAgents .cost", "#usageModels .cost"]) assert.match(await page.locator(selector).first().textContent(), /≈\$12\.34/);
+}
+
+for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
+ test(engine + ": magpie web on phones", async t => {
+ assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
+ const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
+ t.after(() => browser.close());
+ for (const lang of ["en", "zh"]) {
+ for (const width of [360, 390, 430, 820]) {
+ await t.test(`${lang} ${width}: every tab and page fits`, async () => {
+ const opened = [], errors = [], failures = [], page = await open(browser, lang, width, { opened });
+ page.on("pageerror", e => errors.push(e.message));
+ try {
+ for (const view of views) {
+ try {
+ await go(page, view);
+ await save(page, `${engine}-${lang}-${width}-${view}`);
+ await fits(page, view);
+ assert.equal(await page.locator(`#view-${view}`).isVisible(), true);
+ if (view === "agents") await agentsFit(page);
+ if (view === "usage") await usageFits(page);
+ if (view === "library") {
+ await Promise.all([
+ page.waitForResponse(r => new URL(r.url()).pathname === "/api/library/reveal"),
+ page.getByRole("button", { name: lang === "en" ? "Library folder" : "资源库文件夹", exact: true }).click(),
+ ]);
+ assert.deepEqual(opened, [{ path: "/test/library" }], "Library folder must be reachable");
+ }
+ } catch (e) { failures.push(view + ": " + e.message); }
+ }
+ assert.deepEqual(errors, []);
+ assert.deepEqual(failures, []);
+ } finally { await page.close(); }
+ });
+ }
+ await t.test(lang + ": idle scripts cannot scroll, touch momentum can, then protection returns", async () => {
+ const page = await open(browser, lang, 390);
+ try {
+ // Settings now has separate parts. Its Usage part in a short phone
+ // viewport has real content to scroll; General fits without scrolling.
+ await page.setViewportSize({ width: 390, height: 600 });
+ await page.goto("http://magpie.test/?view=settings&tab=usage");
+ await go(page, "settings");
+ await page.waitForTimeout(500);
+ const view = page.locator("#view-settings");
+ assert(await view.evaluate(v => v.scrollHeight > v.clientHeight + 400));
+ await view.evaluate(v => { v.scrollTop = 300; });
+ await page.waitForTimeout(100);
+ assert.equal(await view.evaluate(v => v.scrollTop), 0, "idle code cannot move a phone page");
+ await view.evaluate(async v => {
+ for (const name of ["touchstart", "touchmove", "touchend"]) v.dispatchEvent(new Event(name, { bubbles: true }));
+ // OS momentum outlasts the original 250ms input window; the scroll
+ // events arrive 60ms apart, as they do while a finger's fling slows.
+ for (let i = 1; i <= 10; i++) {
+ await new Promise(r => setTimeout(r, 60));
+ v.scrollTop = i * 30;
+ }
+ });
+ await page.waitForTimeout(100);
+ assert.equal(await view.evaluate(v => v.scrollTop), 300, "continuous momentum must not be pulled back");
+ await page.waitForTimeout(350);
+ await view.evaluate(v => { v.scrollTop = 450; });
+ await page.waitForTimeout(100);
+ assert.equal(await view.evaluate(v => v.scrollTop), 300, "protection returns after momentum stops");
+ } finally { await page.close(); }
+ });
+ for (const width of [900, 1280]) {
+ await t.test(`${lang} ${width}: desktop matches ${base}`, async () => {
+ const current = await open(browser, lang, width, { touch: false });
+ const original = await open(browser, lang, width, { touch: false, original: true });
+ try {
+ for (const view of views) {
+ await go(current, view); await go(original, view);
+ await current.mouse.move(0, 0); await original.mouse.move(0, 0);
+ // Removing hover can repaint the tab's shadow in a later frame.
+ await current.waitForTimeout(350); await original.waitForTimeout(350);
+ const before = PNG.sync.read(await original.screenshot({ animations: "disabled" }));
+ const after = PNG.sync.read(await current.screenshot({ animations: "disabled" }));
+ const sameSize = before.width === after.width && before.height === after.height;
+ let changedPixels = 0;
+ // Chromium can repaint antialiased edges differently even when
+ // comparing HEAD with itself. Count a pixel once only when one
+ // of its channels changes by more than 48; size changes always fail.
+ if (sameSize) for (let i = 0; i < before.data.length; i += 4) {
+ for (let channel = 0; channel < 4; channel++) {
+ if (Math.abs(before.data[i + channel] - after.data[i + channel]) > 48) {
+ changedPixels++;
+ break;
+ }
+ }
+ }
+ const same = sameSize && changedPixels <= 300;
+ if (!same) {
+ await save(original, `${engine}-${lang}-${width}-${view}-desktop-before`);
+ await save(current, `${engine}-${lang}-${width}-${view}-desktop-after`);
+ }
+ assert(same, `${view}: desktop screenshot changed (${sameSize ? changedPixels + " pixels differ by more than 48; limit 300" : "dimensions differ"})`);
+ }
+ } finally { await current.close(); await original.close(); }
+ });
+ }
+ }
+ });
+}