diff --git a/docs/subsystems/gui-shell.md b/docs/subsystems/gui-shell.md index 8adbf685..d855dab1 100644 --- a/docs/subsystems/gui-shell.md +++ b/docs/subsystems/gui-shell.md @@ -29,6 +29,24 @@ three. ## Constraints and failure behavior +### Routing purpose filter + +The request heading's purpose menu in [`routing.js`](../../internal/gui/assets/routing.js) +uses `openProtoMenu`'s live checkbox selection: each tick immediately shows +requests matching any selected purpose, with the menu kept open. An empty +selection, All purposes or Clear filter restores every purpose without +changing the day or request/session view. A live menu stays open when its +redraw clamps the list's scroll; the reader scrolling outside still dismisses it. +Choosing All purposes closes the menu and returns keyboard focus to the +purpose button without scrolling; unticking the last checkbox keeps the +menu open and focused on that checkbox. +Counts, the current story and replay +follow the same filtered list. Selected purposes remain available when a day +has no matching requests. Opening a request from another page clears the +filter only when that request is excluded. Usage's purpose picker remains a +single choice sent to the ledger API. See `purpose-filter.test.cjs` and +`routing-purpose-state.test.cjs`. + ### Desktop fonts Settings → General offers independent interface and code fonts, each with diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 11db533c..95aa1fc8 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -12061,8 +12061,12 @@ function openProtoMenu(anchor, opts, value, choose, head = "Protocol this key sp anchor.classList.add("open"); if (anchor.hasAttribute("aria-expanded")) anchor.setAttribute("aria-expanded", "true"); const outside = (e) => { if (!box.contains(e.target) && !anchor.contains(e.target)) closeProtoMenu(); }; - // Scrolling the menu keeps it open; scrolling outside moves its anchor. - const scroll = (e) => { if (!box.contains(e.target)) closeProtoMenu(); }; + // A live pick can shrink the list or wrap its heading and clamp the scroll. + // Only the reader's scroll dismisses it; the click guard holds its anchor. + const scroll = (e) => { + if (box.contains(e.target) || live && performance.now() >= purposeUntil) return; + closeProtoMenu(); + }; const keys = (e) => { const shown = filter ? items.filter((b) => !b.hidden) : items; const i = shown.indexOf(document.activeElement); diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css index 808938e0..d57f46cb 100644 --- a/internal/gui/assets/routing.css +++ b/internal/gui/assets/routing.css @@ -248,6 +248,8 @@ button.rt-session { cursor: pointer; } .pop.proto-menu.rt-purpose-menu { width: 190px; max-width: calc(100vw - 16px); background: var(--card); } .rt-purpose-menu .pm-head, .rt-purpose-menu .pm-note { display: none; } .rt-purpose-menu .pm-item { align-items: center; padding: 7px 8px; } +.rt-purpose-menu .pm-tick { height: 13px; width: 13px; border: 1px solid var(--line); border-radius: 3px; } +.rt-purpose-menu .on .pm-tick { border-color: var(--muted); } .rt-purpose-menu .pm-name { font-weight: 400; overflow-wrap: anywhere; } #view-routing :where(.rt-col.max440) .rt-req-head .note { flex: 1 1 100%; order: 1; margin: 2px 0 0; } :where(.rt-col.max440) .rt-purpose { max-width: 150px; } diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 363ff0de..654f66cc 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -187,10 +187,10 @@ steady(renderHist); }, "Metrics to show", "rt-metric-menu", "right", true); }; - let purpose = ""; + let purpose = []; purposeClear.onclick = () => { closeProtoMenu(); - purpose = ""; + purpose = []; steady(followListed); purposePick.focus({ preventScroll: true }); }; @@ -1430,7 +1430,7 @@ await loadDays(day); if (!past.some((x) => x.id === id)) past.push(r); } - if (purpose && purposeOf(r.kind) !== purpose) purpose = ""; + if (!matchesPurpose(r)) purpose = []; offline(""); window.show("routing"); pick(r); @@ -1535,13 +1535,13 @@ // listed: the requests the list shows, newest first — the gateway's last // few, or a day the history keeps const allListed = () => (day ? past : [...routes.values()]).slice().sort((a, b) => b.id - a.id); - const matchesPurpose = (r) => !purpose || purposeOf(r.kind) === purpose; + const matchesPurpose = (r) => !purpose.length || purpose.includes(purposeOf(r.kind)); const listed = () => allListed().filter(matchesPurpose); // Match the rows and story: a broken-off 200 fails, an informational note // on an answered request (such as Codex titles being off) does not. const failedRoute = (r) => r.done && outcome(r)[1] === "bad"; function renderStats(rs) { - const scoped = !!(day || purpose), done = rs.filter((r) => r.done); + const scoped = !!(day || purpose.length), done = rs.filter((r) => r.done); const counts = scoped ? { requests: done.length, rerouted: done.reduce((n, r) => n + r.tries.filter((tr, i) => tr.rest && i < r.tries.length - 1).length, 0), @@ -1744,31 +1744,31 @@ const rs = listed(); renderStats(rs); const all = allListed(); - hist.hidden = !all.length && !day && !days.length && !purpose; - const opts = purposeOptions(all.map((r) => purposeOf(r.kind)), purpose); - const selected = opts.find((o) => o.v === purpose); - purposeTools.hidden = opts.length < 2 && !purpose; - purposeTools.classList.toggle("set", !!purpose); - purposeClear.hidden = !purpose; + hist.hidden = !all.length && !day && !days.length && !purpose.length; + const opts = purposeOptions([...all.map((r) => purposeOf(r.kind)), ...purpose]); + const selected = opts.filter((o) => purpose.includes(o.v)); + purposeTools.hidden = opts.length < 2 && !purpose.length; + purposeTools.classList.toggle("set", !!purpose.length); + purposeClear.hidden = !purpose.length; purposeClear.title = t("Clear filter"); purposeClear.setAttribute("aria-label", t("Clear filter")); - const label = purpose ? t("Purpose: {name}", { name: selected?.name || purpose }) : t("Purpose filter"); + const label = purpose.length ? t("Purpose: {name}", { name: selected.map((o) => o.name).join(", ") }) : t("Purpose filter"); setText(purposeLabel, label); purposePick.setAttribute("aria-label", label); - purposePick.title = t("Filter routing by purpose") + (purpose ? "\n" + label : ""); + purposePick.title = t("Filter routing by purpose") + (purpose.length ? "\n" + label : ""); setText(metricLabel, t("Metrics")); metricPick.title = t("Metrics to show"); purposePick.onclick = (e) => { e.stopPropagation(); if (purposePick.classList.contains("open")) return closeProtoMenu(); - // A handful of purposes needs a small menu; explanations stay in tooltips. + // Tick several purposes without closing the menu; an empty list shows all. openProtoMenu(purposePick, [{ v: "", name: t("All purposes"), note: "" }, ...opts].map((o) => ({ ...o, literalName: true, title: o.note || o.v, note: "", - })), purpose, (v) => { - purpose = v; + })), purpose, (keys) => { + purpose = keys; steady(followListed); - purposePick.focus({ preventScroll: true }); - }, "Purpose", "rt-purpose-menu", "right"); + if (!purpose.length) purposePick.focus({ preventScroll: true }); + }, "Purpose", "rt-purpose-menu", "right", true); }; setText(reqLabel, t("Requests")); setText(replayAll, t("Replay them all")); @@ -1792,7 +1792,7 @@ hist.classList.toggle("solo", none); if (none) { const p = el("div", "empty-state"); - p.append(el("b", "", purpose ? t("No requests match these filters.") : day ? t("Nothing on {day}", { day: dayName(day) }) : t("No requests since magpie started")), + p.append(el("b", "", purpose.length ? t("No requests match these filters.") : day ? t("Nothing on {day}", { day: dayName(day) }) : t("No requests since magpie started")), t("Each request an agent sends through magpie shows up here: who answered it, why, and each try.")); if (!day && days.length) p.append(" " + t("Earlier ones are kept by day, in the bar above.")); reqs.replaceChildren(p); @@ -2430,7 +2430,7 @@ function empty() { offline(""); - what.replaceChildren(el("b", "", t(purpose || day ? "No requests match these filters." : "Waiting for a request"))); + what.replaceChildren(el("b", "", t(purpose.length || day ? "No requests match these filters." : "Waiting for a request"))); mode.textContent = t("Send one from any agent routed through magpie and it plays here as it happens: who routing put first and why, each try, and what each answered."); for (const a of agents.values()) a.wire.remove(); agents.clear(); @@ -2445,7 +2445,7 @@ subs.clear(); chip.hidden = true; hubText(); - list.replaceChildren(el("li", "idle", t(purpose || day ? "No requests match these filters." : "No request yet"))); + list.replaceChildren(el("li", "idle", t(purpose.length || day ? "No requests match these filters." : "No request yet"))); say(t("Every request an agent sends to magpie shows up here, routed for real.")); log.hidden = true; renderHist(); // none live, but the days the history keeps are still there to look at diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 56d79fc2..f4fa871a 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -101,7 +101,12 @@ English and Chinese on Chromium and WebKit. It covers title aliases, literal unknown names, unmarked records, pagination and totals, combined failure filters and CSV export, route navigation, session grouping, historical days and the narrow layout. Routing also covers keyboard dismissal and clearing -the purpose while keeping the selected day and session grouping. It uses +the purpose while keeping the selected day and session grouping. Checkbox +selection also covers combining purposes, immediate updates without closing +or scrolling, keyboard toggling, restoring all purposes and returning focus +to the purpose button when All purposes closes the menu, and retaining the +union through history, session grouping and matching route navigation, in +English, Simplified/Traditional Chinese, Japanese and German at narrow widths. It uses isolated API fixtures. Run with `node --test internal/gui/tests/purpose-filter.test.cjs` and the Playwright environment described below. diff --git a/internal/gui/tests/purpose-filter.test.cjs b/internal/gui/tests/purpose-filter.test.cjs index 36e4af6e..db3ad17f 100644 --- a/internal/gui/tests/purpose-filter.test.cjs +++ b/internal/gui/tests/purpose-filter.test.cjs @@ -23,7 +23,7 @@ async function click(page, locator) { } await locator.click(); } -const assets = path.resolve(__dirname, "../assets"); +const assets = process.env.ASSET_DIR || path.resolve(__dirname, "../assets"); const now = new Date(); const day = [now.getFullYear(), now.getMonth() + 1, now.getDate()].map((v) => String(v).padStart(2, "0")).join("-"); const titleKinds = ["thread_title", "thread_title_reconsideration", "title_generation", "title"]; @@ -95,8 +95,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", page.on("pageerror", (e) => { if (!/^ResizeObserver loop completed with undelivered notifications/.test(e.message)) errors.push(e.message); }); await page.route("**/*", server(lang, asked)); const choose = async (id, value) => { - await click(page, page.locator(id)); const routing = id === "#rtPurpose"; + await click(page, page.locator(id)); const wanted = routing ? await page.evaluate((v) => v ? purposeOptions([v], v)[0].name : t("All purposes"), value) : value; const item = page.locator(".proto-menu .pm-item"); const matches = await item.evaluateAll((els, { wanted, routing }) => els.map((e, i) => @@ -104,8 +104,16 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.equal(matches.length, 1, `one menu choice for ${value}`); const name = await item.nth(matches[0]).locator(".pm-name").textContent(); const label = routing ? await page.evaluate(({ value, name }) => value ? t("Purpose: {name}", { name }) : t("Purpose filter"), { value, name }) : name; - await item.nth(matches[0]).click(); + const target = item.nth(matches[0]); + if (!routing || !value || await target.getAttribute("aria-checked") !== "true") await target.click(); + if (routing && value) { + const chosen = await page.locator('.rt-purpose-menu [role="menuitemcheckbox"][aria-checked="true"] .pm-name').allTextContents(); + for (const name of chosen) { + if (name !== wanted) await page.locator(".rt-purpose-menu").getByRole("menuitemcheckbox", { name, exact: true }).click(); + } + } await page.waitForFunction(({ id, label }) => document.querySelector(id + " span").textContent === label, { id, label }); + if (routing && await page.locator(id).getAttribute("aria-expanded") === "true") await page.keyboard.press("Escape"); }; const waitRows = async (n) => page.waitForFunction((n) => document.querySelectorAll("#ledWrap tbody tr.led-row").length === n, n); await page.goto("http://magpie.test/?view=usage&tab=requests"); @@ -197,3 +205,110 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", }); } } + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh", "zh-TW", "ja", "de"]) { + test(`${engine} ${lang}: Routing purpose checkboxes show the union and stay open`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const page = await browser.newPage({ viewport: { width: 1100, height: 800 }, reducedMotion: "reduce" }); + page.setDefaultTimeout(7000); + const errors = []; + page.on("pageerror", (e) => { if (!/^ResizeObserver loop completed with undelivered notifications/.test(e.message)) errors.push(e.message); }); + await page.route("**/*", server(lang, [])); + await page.goto("http://magpie.test/?view=routing"); + await page.locator(".rt-req").nth(7).waitFor(); + const names = await page.evaluate(() => Object.fromEntries(purposeOptions(["kind:thread_title", "unmarked", "kind:review"]).map((o) => [o.v, o.name]))); + const menu = page.locator(".rt-purpose-menu"); + const title = menu.getByRole("menuitemcheckbox", { name: names["kind:thread_title"], exact: true }); + const unmarked = menu.getByRole("menuitemcheckbox", { name: names.unmarked, exact: true }); + const review = menu.getByRole("menuitemcheckbox", { name: names["kind:review"], exact: true }); + const waitRows = async (n) => page.waitForFunction((n) => document.querySelectorAll(".rt-req").length === n, n); + await click(page, page.locator("#rtPurpose")); + assert.equal(await menu.getByRole("menuitemcheckbox").count(), purposes.length, "purposes are checkbox choices"); + const scrollTop = await page.locator("#view-routing").evaluate((e) => e.scrollTop); + await title.click(); + await waitRows(4); + assert.equal(await page.locator(".rt-req").count(), 4, "all four title aliases match one checkbox"); + await unmarked.click(); + await waitRows(5); + assert.equal(await page.locator(".rt-req").count(), 5, "selected purposes are combined"); + assert.equal(await menu.isVisible(), true, "ticking keeps the menu open"); + assert.equal(await title.getAttribute("aria-checked"), "true"); + assert.equal(await unmarked.getAttribute("aria-checked"), "true"); + assert.equal(await unmarked.evaluate((e) => e === document.activeElement), true); + assert.equal(await page.locator("#view-routing").evaluate((e) => e.scrollTop), scrollTop, "ticking does not scroll the page"); + const label = await page.locator("#rtPurpose span").textContent(); + assert(label.includes(names["kind:thread_title"]) && label.includes(names.unmarked), "the heading names every selected purpose"); + await review.focus(); + await page.keyboard.press("Space"); + await waitRows(6); + assert.equal(await page.locator(".rt-req").count(), 6, "keyboard adds another purpose"); + assert.equal(await review.getAttribute("aria-checked"), "true"); + await page.keyboard.press("Enter"); + await waitRows(5); + assert.equal(await page.locator(".rt-req").count(), 5, "keyboard removes a purpose"); + assert.equal(await menu.isVisible(), true); + await page.keyboard.press("Escape"); + assert.equal(await page.locator("#rtPurpose").evaluate((e) => e === document.activeElement), true); + await page.locator(".rt-group-by button").last().click(); + assert.match(await page.locator(".rt-session .summary").textContent(), /^5/); + await page.locator(".rt-days .rt-day").nth(1).click(); + assert.equal(await page.locator(".rt-req").count(), 5, "the same union applies to history and sessions"); + // Cross-page navigation keeps the union if its target already matches. + await page.evaluate(() => window.openRoute(95, new Date().toISOString())); + assert.equal(await page.locator(".rt-req").count(), 5); + assert.equal(await page.locator("#rtPurpose span").textContent(), label); + for (const width of [1100, 560, 360]) { + await page.setViewportSize({ width, height: 800 }); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await click(page, page.locator("#rtPurpose")); + const bounds = await menu.boundingBox(); + assert(bounds.x >= 0 && bounds.x + bounds.width <= width, `menu fits at ${width}px`); + assert.equal(await title.getAttribute("aria-checked"), "true"); + assert.equal(await unmarked.getAttribute("aria-checked"), "true"); + await title.click(); + await waitRows(1); + assert.equal(await page.locator(".rt-req").count(), 1, "unticking keeps the other purpose"); + await title.click(); + await waitRows(5); + assert.equal(await page.locator(".rt-req").count(), 5); + await page.keyboard.press("Escape"); + } + await click(page, page.locator("#rtPurpose")); + const view = await page.locator("#view-routing").boundingBox(); + await page.mouse.move(view.x + 10, view.y + 80); + await page.mouse.wheel(0, -200); + await menu.waitFor({ state: "detached" }); + await click(page, page.locator("#rtPurpose")); + await title.click(); + await unmarked.click(); + await waitRows(8); + assert.equal(await page.locator(".rt-req").count(), 8, "unticking the last purpose restores all"); + assert.equal(await page.locator("#rtPurposeClear").isHidden(), true); + assert.equal(await menu.isVisible(), true, "unticking the last purpose keeps the menu open"); + assert.equal(await unmarked.evaluate((e) => e === document.activeElement), true, "an open menu keeps focus on the checkbox"); + await title.click(); + await unmarked.click(); + const all = await page.evaluate(() => t("All purposes")); + const beforeAll = await page.locator("#view-routing").evaluate((e) => e.scrollTop); + await menu.locator(".pm-item").filter({ has: page.locator(".pm-name", { hasText: all }) }).focus(); + await page.keyboard.press("Enter"); + await waitRows(8); + assert.equal(await page.locator(".rt-req").count(), 8, "All purposes resets the whole union"); + assert.equal(await menu.count(), 0); + assert.equal(await page.locator("#rtPurpose").getAttribute("aria-expanded"), "false"); + assert.equal(await page.locator("#rtPurpose").evaluate((e) => e === document.activeElement), true, "All purposes returns keyboard focus to the purpose button"); + assert.equal(await page.locator("#view-routing").evaluate((e) => e.scrollTop), beforeAll, "returning focus does not scroll the page"); + await page.keyboard.press("Enter"); + assert.equal(await menu.isVisible(), true, "the purpose button can reopen the menu with the keyboard"); + await title.click(); + await unmarked.click(); + await page.keyboard.press("Escape"); + await click(page, page.locator("#rtPurposeClear")); + await waitRows(8); + assert.equal(await page.locator(".rt-req").count(), 8, "Clear filter also resets the whole union"); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/gui/tests/routing-purpose-state.test.cjs b/internal/gui/tests/routing-purpose-state.test.cjs index bbab794d..8e2e29e3 100644 --- a/internal/gui/tests/routing-purpose-state.test.cjs +++ b/internal/gui/tests/routing-purpose-state.test.cjs @@ -74,8 +74,16 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const choose = async (value) => { await click(page, page.locator("#rtPurpose")); const name = await page.evaluate((v) => v ? purposeOptions([v], v)[0].name : t("All purposes"), value); - await page.locator(".rt-purpose-menu .pm-item").filter({ has: page.locator(".pm-name", { hasText: name }) }).click(); + const target = page.locator(".rt-purpose-menu .pm-item").filter({ has: page.locator(".pm-name", { hasText: name }) }); + if (!value || await target.getAttribute("aria-checked") !== "true") await target.click(); + if (value) { + const chosen = await page.locator('.rt-purpose-menu [role="menuitemcheckbox"][aria-checked="true"] .pm-name').allTextContents(); + for (const other of chosen) { + if (other !== name) await page.locator(".rt-purpose-menu").getByRole("menuitemcheckbox", { name: other, exact: true }).click(); + } + } await page.waitForFunction((v) => document.querySelector(".rt-purpose-tools").classList.contains("set") === !!v, value); + if (await page.locator("#rtPurpose").getAttribute("aria-expanded") === "true") await page.keyboard.press("Escape"); }; const send = async (rs) => { for (let i = 0; i < 100 && !feed.next; i++) await page.waitForTimeout(20); @@ -152,8 +160,28 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await send([req(108, "thread_title")]); await page.waitForFunction((n) => document.querySelector("#rt .rt-stats b").textContent === String(n), Number(beforeDone[0]) + 1); assert.deepEqual((await counts()).slice(1), beforeDone.slice(1)); + // Every selected purpose contributes to the live story and counters. + await click(page, page.locator("#rtPurpose")); + const reviewName = await page.evaluate(() => purposeOptions(["kind:review"])[0].name); + await page.locator(".rt-purpose-menu").getByRole("menuitemcheckbox", { name: reviewName, exact: true }).click(); + await page.waitForFunction((name) => document.querySelector("#rtPurpose span").textContent.includes(name), reviewName); + await page.keyboard.press("Escape"); + const unionCounts = await counts(); + await send([req(109, "review", 500, "Selected review failed")]); + await story(109); + assert.deepEqual(await counts(), [String(Number(unionCounts[0]) + 1), unionCounts[1], String(Number(unionCounts[2]) + 1)]); + await send([req(110, "guardian")]); + await story(109); + assert.equal((await counts())[0], String(Number(unionCounts[0]) + 1), "an unselected purpose cannot change union counts"); + await click(page, page.locator(".rt-days .rt-day").nth(1)); + await story(50); + await click(page, page.locator("#rtPurpose")); + assert.equal(await page.locator('.rt-purpose-menu [role="menuitemcheckbox"][aria-checked="true"]').count(), 2, "a purpose absent from history stays selected"); + await page.keyboard.press("Escape"); + await click(page, page.locator(".rt-days .rt-day").first()); + await story(109); await choose(""); - await story(108); + await story(110); assert.deepEqual(await counts(), ["20", "5", "7"], "the menu's All purposes option also clears the routing scope"); // Browser interpretation uses explicit canonical keys, including unknown // values that happen to be Object.prototype property names.