From fdcdb5af210dcd063b7dd58fe3206bc4c864f699 Mon Sep 17 00:00:00 2001 From: yetone Date: Wed, 7 Oct 2026 00:25:28 +0800 Subject: [PATCH] =?UTF-8?q?usage:=20the=20requests=20table=20fits=20a=20na?= =?UTF-8?q?rrow=20window=20with=20the=20reporter's=20rows,=20Speed=20and?= =?UTF-8?q?=20Status=20in=20sight=20(#860,=20huoranxuanyuan:=20=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E8=BE=83=E7=AA=84=E7=9A=84=E6=8F=90=E7=A4=BA=E5=87=BA?= =?UTF-8?q?=E7=8E=B0=E4=BA=86=EF=BC=8C=E9=80=9F=E5=BA=A6=E5=92=8C=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=88=97=E4=BB=8D=E8=A2=AB=E6=8C=A4=E5=87=BA=E5=8E=BB?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hiding the optional columns (tight) wasn't enough for rows whose provider · account cell takes two lines and whose cost carries a price-reference line: on macOS fonts that table needed 858 (zh) / 923 (en) CSS px, and the reporter's 1569px window at 175% is about 897. When the table still overflows once tight, ledFit adds tighter: the price-reference line goes (it stays in the cost's tooltip and the row's details), model and provider names are cut at 110px with their tooltips whole, cells have 4px sides and headers may wrap. The minimum is now 734 (zh) / 760 (en). The ResizeObserver refits until tighter. usage-ledger-fit.test.cjs is built from the reporter's rows (Codex, openai · chatgpt.com, codex-auto-review priced as gpt-5.6-luna): at 820px the table fits with Status in sight, the hint shows, the reference line is hidden and is in the cost's title and the details; at 1100px the line shows. Chromium and WebKit, en and zh. Not run on Windows. --- internal/gui/assets/app.css | 4 + internal/gui/assets/app.js | 11 +- internal/gui/tests/usage-ledger-fit.test.cjs | 121 +++++++++++++++++++ 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 internal/gui/tests/usage-ledger-fit.test.cjs diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 0a4ef7e1..7efb11c7 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -916,6 +916,10 @@ body.window .field.solo { grid-column: auto; } .led.tight .led-dl .lc-opt { display: block; } .led.tight .model, .led.tight .where { max-width: 160px; } .led.tight th, .led.tight td { padding-left: 6px; padding-right: 6px; } +.led.tighter .price-reference { display: none; } +.led.tighter .model, .led.tighter .where { max-width: 110px; } +.led.tighter th, .led.tighter td { padding-left: 4px; padding-right: 4px; } +.led.tighter th { white-space: normal; } .led-tight { font-size: 11.5px; color: var(--muted); } .led-tight[hidden] { display: none; } .led .where { color: var(--fg-2); } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index f48f9322..43e41357 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -15297,7 +15297,7 @@ const ledGrow = new ResizeObserver(() => { ledGrowFrame = requestAnimationFrame(() => { ledGrowFrame = 0; const wrap = $("#ledWrap"), table = wrap.querySelector("table.led"); - if (table && !table.classList.contains("tight") && wrap.clientWidth && wrap.scrollWidth > wrap.clientWidth + 1) ledFit(); + if (table && !table.classList.contains("tighter") && wrap.clientWidth && wrap.scrollWidth > wrap.clientWidth + 1) ledFit(); ledHScroll(); }); }); @@ -15333,9 +15333,14 @@ function ledFit() { const wrap = $("#ledWrap"), table = wrap.querySelector("table.led"); let hint = $("#ledTight"); if (!table) { if (hint) hint.hidden = true; return; } - table.classList.remove("tight"); - const tight = wrap.scrollWidth > wrap.clientWidth + 1; + table.classList.remove("tight", "tighter"); + const over = () => wrap.scrollWidth > wrap.clientWidth + 1; + const tight = over(); table.classList.toggle("tight", tight); + // still too wide (#860: a Windows window at 175%, its wider fonts): the + // price reference line goes too, in the cost's tooltip and the details + // as well, and names are cut shorter, whole in their tooltips + if (tight && over()) table.classList.add("tighter"); if (!hint) { hint = el("span", "led-tight"); hint.id = "ledTight"; diff --git a/internal/gui/tests/usage-ledger-fit.test.cjs b/internal/gui/tests/usage-ledger-fit.test.cjs new file mode 100644 index 00000000..570a78a2 --- /dev/null +++ b/internal/gui/tests/usage-ledger-fit.test.cjs @@ -0,0 +1,121 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// The Usage page's Requests table fits a narrow window with the reporter's +// own rows (#860, huoranxuanyuan, a second round: at 1569px on Windows at +// 175%, about 900 CSS px, the hint said columns were left out and Speed and +// Status were still cut off). Their rows are Codex's through openai · +// chatgpt.com with a Responses badge, one priced from another model, whose +// "Price reference" line widened the Cost column. Where leaving out Sent, +// Effort and the cache isn't enough, that line goes too (it is in the cost's +// tooltip and the row's details) and names are cut shorter; a window wide +// enough keeps it. Chromium and WebKit, English and Chinese; the API is faked. +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 reader = require("./reader.cjs"); + +const assets = path.resolve(__dirname, "../assets"); +const now = Date.now(); +const H = 3600e3; +const hour = (i) => new Date(Math.floor(now / H) * H - i * H).toISOString(); + +// the reporter's rows, from their screenshot +const R = (i, req, inn, out, ms, ttft, ref) => ({ route_id: 900 + i, t: new Date(now - (i + 1) * 60e3).toISOString(), agent: "codex", agentName: "Codex", icon: "codex-color", + provider: "openai", providerName: "openai", host: "chatgpt.com", ep: "/v1/responses", req, model: req, served: req, effort: "medium", + in: inn, out, cache_read: 110000, cache_write: 0, ms, ttft_ms: ttft, status: 200, rid: "r" + i, cost: 0.02, priced: true, ...(ref ? { pricing_model: ref } : {}) }); +const ROWS = [R(0, "gpt-6.1-sol", 3660, 240, 14000, 11000), R(1, "gpt-6.1-sol", 4102, 145, 11000, 10000), R(2, "codex-auto-review", 4493, 134, 11000, 6400, "gpt-5.6-luna"), R(3, "gpt-6.1-sol", 1030, 93, 8900, 6900)]; +const FAST = { id: "gpt-6.1-sol", calls: 3, input: 1, output: 478, cache_read: 0, cache_write: 0, cost: 0.06, timed: 3, ttft_ms: 27900, decode_ms: 6000, decode_out: 478 }; +const SLOW = { id: "codex-auto-review", calls: 1, input: 1, output: 134, cache_read: 0, cache_write: 0, cost: 0.02, timed: 1, ttft_ms: 6400, decode_ms: 4600, decode_out: 134 }; +const part = (x) => ({ calls: x.calls, tokens: x.output, cost: x.cost, timed: x.timed, ttft_ms: x.ttft_ms, decode_ms: x.decode_ms, decode_out: x.decode_out }); +const sum = (k) => FAST[k] + SLOW[k]; + +function ledger(period) { + return { + period, rows: ROWS, offset: 0, total: ROWS.length, calls: ROWS.length, errors: 0, + input: sum("input"), output: sum("output"), cache_read: 0, cache_write: 0, cost: sum("cost"), unpriced: 0, + // 3665 tokens in 50.6 s after the first: 72 tok/s + timed: sum("timed"), ttft_ms: sum("ttft_ms"), decode_ms: sum("decode_ms"), decode_out: sum("decode_out"), + by: { model: [FAST, SLOW] }, bucket: "hour", + series: [ + { label: "", time: hour(1), calls: 14, input: 1, output: 1890, ...{ timed: 14, ttft_ms: 8400, decode_ms: 25200, decode_out: 1890 }, by: { model: { "glm-fast": part({ ...FAST, calls: 14, output: 1890, timed: 14, ttft_ms: 8400, decode_ms: 25200, decode_out: 1890 }) } } }, + // hour(0): the hour's own is 1910 tokens in 27.4 s (69.7 tok/s) while + // glm-fast answered 1755 in 23.4 s (75.0), so its mark leaves the column + // and gets a leader line; kimi-slow (10) stays under it. hour(1) is the + // hour's own equal to the one model that answered in it, so no line + { label: "", time: hour(0), calls: 15, input: 1, output: 1910, ...{ timed: 14, ttft_ms: 8400, decode_ms: 27400, decode_out: 1910 }, by: { model: { "glm-fast": part({ ...FAST, calls: 14, output: 1890, timed: 13, ttft_ms: 7800, decode_ms: 23400, decode_out: 1755 }), "kimi-slow": part(SLOW) } } }, + ], + agents: [{ id: "claude", name: "Claude Code", icon: "claudecode-color" }], + }; +} + +const STATE = (lang) => ({ + agents: [ + { id: "claude", name: "Claude Code", path: "/t/c.json", icon: "claudecode-color", wired: true, fields: [{ key: "model", label: "model", value: "a", options: [{ value: "a", label: "Claude Sonnet 4.5", ref: "claude/a" }] }] }, + { id: "codex", name: "Codex", path: "/t/c.toml", icon: "codex-color", wired: true, fields: [{ key: "model", label: "model", value: "b", options: [{ value: "b", label: "GPT", ref: "openai/b" }] }] }, + ], + profiles: [], settings: { lang, theme: "light" }, fx: { rate: 7.2, at: new Date().toISOString() }, +}); + +function server(lang) { + 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:false};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(STATE(lang)); + if (url.pathname === "/api/usage/requests") return json(ledger(url.searchParams.get("period"))); + if (url.pathname === "/api/usage/quotas") return json([]); + if (url.pathname === "/api/usage") return json({ calls: 0, errors: 0, input: 0, output: 0, reasoning: 0, unpriced: 0, cost: 0, bucket: "day", series: [], agents: [], models: [], path: "" }); + if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], models: [], gateway: { running: false } }); + if (url.pathname === "/api/sessions") return json({ sessions: [], dirs: [] }); + if (url.pathname === "/api/sessions/stats") return json({ from: "", to: "", days: [], agents: {} }); + if (url.pathname === "/api/groups") return json({ groups: [], models: [] }); + 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)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: the reporter's rows fit a narrow window`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const errors = []; + const open = async (width) => { + const p = await (await browser.newContext({ viewport: { width, height: 760 }, reducedMotion: "reduce" })).newPage(); + p.setDefaultTimeout(5000); + p.on("pageerror", (e) => errors.push(e.message)); + await p.route("**/*", server(lang)); + await p.goto("http://magpie.test/"); + await p.locator('[data-view="usage"]').first().click(); + await p.locator("#usageTab .opt").nth(1).click(); + await p.locator("#ledWrap .led tbody tr").first().waitFor(); + await p.waitForTimeout(300); + return p; + }; + const fits = (p) => p.locator("#ledWrap").evaluate((e) => { + const box = e.getBoundingClientRect(), last = [...e.querySelectorAll("thead th")].filter((th) => th.offsetParent).at(-1).getBoundingClientRect(); + return { scroll: e.scrollWidth <= e.clientWidth + 1, status: last.right <= box.right + 1 }; + }); + + // narrower than the table is with Sent, Effort and the cache left out + const p = await open(820); + assert.deepEqual(await fits(p), { scroll: true, status: true }, "the table fits, Status in sight"); + assert(await p.locator("#ledTight").isVisible(), "the hint says where the columns went"); + const ref = p.locator(".led tbody tr.led-row").nth(2).locator("td.cost"); + assert.equal(await ref.locator(".price-reference").isVisible(), false, "the price reference line is left out"); + assert((await ref.getAttribute("title")).includes("gpt-5.6-luna"), "and is in the cost's tooltip"); + await reader.click(p, p.locator(".led tbody tr.led-row").nth(2).locator("td").nth(2)); + assert((await p.locator(".led tbody tr.led-detail").textContent()).includes("gpt-5.6-luna"), "and in the row's details"); + + // wide enough: the line is there + const q = await open(1100); + assert.deepEqual(await fits(q), { scroll: true, status: true }); + assert(await q.locator(".led tbody tr.led-row").nth(2).locator(".price-reference").isVisible(), "the price reference line at a wider window"); + assert.deepEqual(errors, []); + }); + } +}