diff --git a/internal/gui/assets/context.css b/internal/gui/assets/context.css index a25933af..afaf82fa 100644 --- a/internal/gui/assets/context.css +++ b/internal/gui/assets/context.css @@ -109,6 +109,17 @@ i.dot.free { background: var(--k-free); box-shadow: inset 0 0 0 1px color-mix(in /* Routing: the card under the story of the request */ .rt-log .ctx-card { margin-top: 12px; } +/* folded: its head alone, how full it is beside the title */ +.ctx-fold { display: inline-flex; align-items: center; gap: 6px; min-width: 0; height: 22px; margin: -2px 0 -2px -4px; padding: 0 6px 0 4px; border: 0; border-radius: 6px; background: none; color: inherit; font: inherit; cursor: pointer; } +.ctx-fold:hover { background: var(--pill); } +.ctx-fold .tw { display: inline-flex; width: 10px; flex: none; color: var(--faint); } +.ctx-fold .tw svg { transition: transform .2s var(--ease, ease); } +.ctx-card.shut .ctx-fold .tw svg { transform: rotate(-90deg); } +.ctx-short { display: none; flex: none; font-size: 11.5px; color: var(--muted); white-space: nowrap; } +.ctx-card.shut .ctx-short { display: inline; } +.ctx-card.shut { padding-block: 10px; } +.ctx-card.shut > :not(.ctx-head) { display: none; } +:where(.ctx-card.shut.max420) .ctx-model { display: none; } /* the Usage page's Context tab */ .ctx-agents { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); gap: 12px; margin: 4px 0 18px; } diff --git a/internal/gui/assets/context.js b/internal/gui/assets/context.js index d7778ed9..9a53dbc0 100644 --- a/internal/gui/assets/context.js +++ b/internal/gui/assets/context.js @@ -227,8 +227,14 @@ // ctxUpdate(r, opts) draws another route, or the same one further on, in // it: what is the same stays the same nodes, its grid's cells among them, // so nothing flashes or comes in again and the grid keeps its height + // a card that folds (Routing's, opts.foldable) keeps the reader's + // choice: folded, it is its head alone, with how full the window is + // beside the title, so the request list under it has the room + // (0xBCD18E on X: it took most of the height, three rows were left) + let ctxShut = false; + try { ctxShut = localStorage.getItem("magpie.ctxShut") === "1"; } catch {} function ctxCard(r, opts = {}) { - const card = el("div", "ctx-card"); + const card = el("div", "ctx-card" + (opts.foldable && ctxShut ? " shut" : "")); card.dataset.place = opts.place || ""; if (cardWidth[card.dataset.place] !== undefined) sizeCard(card, cardWidth[card.dataset.place]); cardSizes.observe(card); @@ -343,13 +349,32 @@ // the head: what it is and how it was counted const head = el("div", "ctx-head"); - const title = el("span", "ctx-title", t("Context window")); + let title = el("span", "ctx-title", t("Context window")); + if (opts.foldable) { + const fold = el("button", "ctx-fold"); + fold.type = "button"; + fold.setAttribute("aria-expanded", String(!card.classList.contains("shut"))); + const tw = el("span", "tw"); + tw.append(svg(CHEV, 10, 1.7)); + fold.append(tw, title); + // the head is morphed, so the button that is there is the target + fold.onclick = (e) => { + ctxShut = !card.classList.contains("shut"); + card.classList.toggle("shut", ctxShut); + tip.hidden = true; + e.currentTarget.setAttribute("aria-expanded", String(!ctxShut)); + try { localStorage.setItem("magpie.ctxShut", ctxShut ? "1" : "0"); } catch {} + }; + title = fold; + } const st = el("span", "ctx-state " + (live ? "live" : p.counted ? "counted" : "est")); st.append(el("i"), t(live ? "Live" : p.counted ? "Counted" : "Estimated")); st.title = t(live ? "The request is on its way: the prompt is estimated from what the agent sent" : p.counted ? "As many tokens as the vendor counted; the parts are measured from the request and scaled to it" : "Estimated from what the agent sent: the vendor didn't say how many tokens it read"); - head.append(title, el("span", "grow")); + head.append(title); + if (opts.foldable) head.append(el("span", "ctx-short", fmtK(p.tokens) + (window ? " / " + fmtK(window) + " · " + pct(full) : ""))); + head.append(el("span", "grow")); if (r.model) head.append(el("code", "ctx-model", r.model)); head.append(st); diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 388eb4b9..2e14e6e8 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -1452,7 +1452,7 @@ const card = ctxBox.firstElementChild; const draw = card?.ctxUpdate && (still || !pinned) ? card.ctxUpdate : (r, o) => ctxBox.replaceChildren(window.ctxCard(r, o)); draw(r, { - still, series, tab: ctxTab, place: "routing", + still, series, tab: ctxTab, place: "routing", foldable: true, onTab: (id) => { ctxTab = id; }, crumbs: [agentName(r.agent), sess && (sess.length > 14 ? sess.slice(0, 12) + "…" : sess), "#" + r.id], onPoint: (pt) => { diff --git a/internal/gui/tests/routing-ctx-fold.test.cjs b/internal/gui/tests/routing-ctx-fold.test.cjs new file mode 100644 index 00000000..fd09491e --- /dev/null +++ b/internal/gui/tests/routing-ctx-fold.test.cjs @@ -0,0 +1,153 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Routing: the context window's card folds to its head (0xBCD18E on X: it +// took most of the height, so the request list under it showed three +// rows, even on a portrait screen). Folded, it is one line with how full +// the window is beside the title, the list under it moves up, and it stays +// folded for the next request picked and after the page comes back; +// opened, it is all there again. In Chromium and WebKit, at a phone's +// width and a desk's. +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 now = Date.now(); +const iso = (ms) => new Date(ms).toISOString(); +const seat = { id: "codex@me", provider: "codex", name: "ChatGPT", who: "me@example.com", kind: "account", model: "gpt-6.1-sol", known: true, plan: "Pro" }; + +const prompt = (n) => ({ + window: 272000, tokens: 90000 + n * 8000, counted: true, turns: n, + parts: [ + { kind: "system", tokens: 9000, items: [{ name: "prompt", tokens: 9000 }] }, + { kind: "tools", tokens: 12000, items: [{ name: "exec_command", tokens: 7000 }, { name: "apply_patch", tokens: 5000 }] }, + { kind: "files", tokens: 30000 + n * 4000, items: [{ name: "/work/app/routing.js", tag: "read", tokens: 20000 + n * 3000 }, { name: "/work/app/context.js", tag: "read", tokens: 10000 + n * 1000 }] }, + { kind: "results", tokens: 20000 + n * 2000, items: [{ name: "exec_command", tag: "result", n: n + 2, tokens: 20000 + n * 2000 }] }, + { kind: "chat", tokens: 19000 + n * 2000, items: [{ name: "turn", tag: "turns", n, tokens: 19000 + n * 2000 }] }, + ], +}); +function req(id) { + const t0 = now - (200 - id) * 1000; + return { + id, seq: id * 10, time: iso(t0), agent: "codex", session: "019a-session", model: "gpt-6.1-sol", provider: "codex", + order: [seat], tries: [{ id: seat.id, model: seat.model, start: iso(t0), done: true, status: 200, ms: 4000 }], + done: true, status: 200, ms: 4000, tokens: 2000, cost: 0.05, priced: true, usage: [{ in: 3000, cache_read: 87000 + id * 100, out: 400 }], + prompt: prompt(id - 100), + }; +} +const routes = [req(101), req(102), req(103)]; + +function serve() { + const state = { agents: [{ id: "codex", name: "Codex", path: "/test/codex", fields: [] }], profiles: [], settings: { lang: "en", theme: "light" } }; + 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/gateway/trace") { + if (url.searchParams.get("wait")) return; // nothing more comes + return json({ mine: true, now: new Date().toISOString(), seq: routes[routes.length - 1].seq, totals: { requests: 0, rerouted: 0, errors: 0 }, routes }); + } + if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] }); + if (url.pathname === "/api/groups") return json({ groups: [], models: [], pools: [] }); + if (url.pathname === "/api/providers") return json({ providers: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } }); + 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).catch(() => ""), contentType }); + }; +} + +const look = (page) => page.evaluate(() => { + const card = document.querySelector(".rt-ctx .ctx-card"), fold = card.querySelector(".ctx-fold"), short = card.querySelector(".ctx-short"); + const shown = (e) => !!e && e.getClientRects().length > 0; + return { + shut: card.classList.contains("shut"), + expanded: fold.getAttribute("aria-expanded"), + height: card.offsetHeight, + short: shown(short) ? short.textContent : null, + grid: shown(card.querySelector(".ctx-waffle")), + contents: shown(card.querySelector(".ctx-contents")), + cells: card.querySelectorAll(".ctx-waffle > i").length, + crumbs: card.querySelector(".ctx-crumbs").textContent, + overflow: card.scrollWidth - card.clientWidth + (document.documentElement.scrollWidth - document.documentElement.clientWidth), + after: card.getBoundingClientRect().bottom, + }; +}); + +// the reader scrolls to what they click with the wheel, as at a phone's +// width the card is under the request list +async function click(page, b) { + const h = page.viewportSize().height; + for (let i = 0; i < 20; i++) { + const r = await b.boundingBox(); + if (r && r.y >= 0 && r.y + r.height <= h) break; + await page.mouse.move(page.viewportSize().width / 2, h / 2); + await page.mouse.wheel(0, r && r.y < 0 ? -200 : 200); + await page.waitForTimeout(100); + } + await b.click(); + await page.waitForTimeout(300); +} +const fold = (page) => click(page, page.locator(".rt-ctx .ctx-fold")); + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(`${engine}: Routing's context window folds to its head and stays folded`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + for (const [width, height] of [[420, 760], [1100, 760]]) { + await t.test(`${width}x${height}`, async () => { + const context = await browser.newContext({ viewport: { width, height } }); + const page = await context.newPage(); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve()); + await page.goto("http://magpie.test/?view=routing"); + await page.locator(".rt-ctx .ctx-waffle").waitFor(); + await page.waitForTimeout(800); + + const open = await look(page); + assert.equal(open.shut, false, "it starts open"); + assert.equal(open.expanded, "true"); + assert(open.grid && open.contents, "open, the grid and contents are there"); + assert.equal(open.short, null, "open, the short figure isn't shown"); + + await fold(page); + const shut = await look(page); + assert.equal(shut.shut, true, "a click folds it"); + assert.equal(shut.expanded, "false"); + assert(!shut.grid && !shut.contents, "folded, the grid and contents are gone"); + assert.equal(shut.short, "114K / 272K · 42%", "folded, how full it is is beside the title"); + assert(shut.height < 60, `folded, it is one line: ${shut.height}px`); + assert(shut.after < open.after - 200, `what is under it moves up: ${open.after} -> ${shut.after}`); + assert(shut.overflow <= 0, `nothing runs over at ${width}px: ${shut.overflow}`); + + // another request picked: still folded, telling of that one + await click(page, page.locator(".rt-req").last()); + const other = await look(page); + assert.equal(other.shut, true, "the next request's card is folded too"); + assert.notEqual(other.crumbs, shut.crumbs, "it tells of the request picked"); + + // the page comes back: still folded + await page.reload(); + await page.locator(".rt-ctx .ctx-card").waitFor(); + await page.waitForTimeout(500); + assert.equal((await look(page)).shut, true, "it stays folded after the page comes back"); + + // opened again, it is all there + await fold(page); + const again = await look(page); + assert.equal(again.shut, false); + assert.equal(again.expanded, "true"); + assert(again.grid && again.contents, "opened, the grid and contents are back"); + assert.equal(again.cells, 400); + assert.equal(again.height, open.height, "and it is as tall as it was"); + assert.deepEqual(errors, []); + await context.close(); + }); + } + }); +}