diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css
index 1ad0a8e7..ed2f0538 100644
--- a/internal/gui/assets/app.css
+++ b/internal/gui/assets/app.css
@@ -503,7 +503,7 @@ body.window .field.solo { grid-column: auto; }
.field .v { min-width: 0; flex: 0 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; font-weight: 500; }
.field .chev { color: var(--faint); display: inline-flex; margin-left: auto; padding-left: 1px; }
.field:hover .chev, .field.open .chev { color: var(--muted); }
-.field.flash { animation: flash 1.2s ease-out; }
+.field.flash, .subscription-card.flash, .pq-card.flash { animation: flash 1.2s ease-out; }
@keyframes flash { 0%, 35% { box-shadow: inset 0 0 0 1px var(--green); background: var(--green-soft); } 100% {} }
/* ---- usage ---------------------------------------------------------------- */
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index 2522f12a..e5a5a2a1 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -4,6 +4,8 @@ const $$ = (s) => document.querySelectorAll(s);
const params = new URLSearchParams(location.search);
const mode = params.get("mode") || "window";
document.body.classList.add(mode);
+// The panel loads Wails for ExecJS readiness, but stays attached to the tray.
+if (mode === "panel") $("header.top").style.setProperty("--wails-draggable", "no-drag");
// `magpie web`: the page in a browser tab, with no window of the app's
// around it — it opens links itself, and what is the desktop's is left out
const web = !!window.bootPrefs?.web;
@@ -26,7 +28,8 @@ if (/^Win/.test(navigator.platform)) document.documentElement.classList.add("win
// Outside the app — a browser on the gateway's page, or on `magpie web` —
// there is no runtime, and it isn't asked for (a 404 in the browser's
// console, Jorben on Discord).
-const winRuntime = mode === "window" && !web ? import("/wails/runtime.js").catch(() => null) : Promise.resolve(null);
+// The panel also needs runtime readiness to drain Go's queued ExecJS calls.
+const winRuntime = !web ? import("/wails/runtime.js").catch(() => null) : Promise.resolve(null);
if (params.get("theme")) document.documentElement.dataset.theme = params.get("theme");
// the saved language and theme from boot.js, so the first paint is in them
if (window.bootPrefs) {
@@ -9032,6 +9035,7 @@ const tokensOf = (t) => t.input + t.output;
function renderQuotas() {
renderPanelQuota();
const subscriptions = $("#subscriptionUsage");
+ const restoreFlash = keepQuotaFlash(subscriptions);
subscriptions.replaceChildren();
// the allowances' own heading, apart from the period's cost: used or
// left turns their meters, and is only there when some card has one (a
@@ -9073,6 +9077,8 @@ function renderQuotas() {
const pick = folded ? usageShown(subs) : null;
const card = el("div", "subscription-card" + (first.user ? " several" : ""));
card.dataset.key = first.provider;
+ card.dataset.provider = first.provider;
+ if (!first.user) card.dataset.card = first.provider;
const head = el("div", "subscription-head");
head.append(groups.length > 1 ? usageHandle(first, card) : icon(first.icon), el("b", "", first.name));
if (!first.user && (first.plan || first.until)) head.append(planSpan(first));
@@ -9084,6 +9090,7 @@ function renderQuotas() {
const [meters, every] = familyQuota(sub);
if (sub.user) {
const who = el("div", "subscription-account");
+ who.dataset.card = trayCardID(sub);
const u = el("span", "user", sub.user);
u.title = sub.user;
who.append(u);
@@ -9123,6 +9130,8 @@ function renderQuotas() {
if (subs.length > 1) card.append(usageMore(first.provider, subs.length - 1, folded, "text quota-more"));
subscriptions.append(card);
}
+ restoreFlash();
+ requestAnimationFrame(focusQuotaCard);
}
// A subscription with several accounts shows one of them, the others behind
@@ -9146,13 +9155,16 @@ function usageMore(provider, more, folded, cls) {
b.type = "button";
b.setAttribute("aria-expanded", String(!folded));
b.onclick = () => {
- if (folded) usageOpen.add(provider); else usageOpen.delete(provider);
- try { localStorage.setItem("magpie.usageOpen", JSON.stringify([...usageOpen])); } catch {}
- renderQuotas();
+ setUsageOpen(provider, folded);
if (mode !== "panel") backToReader($("#view-usage"));
};
return b;
}
+function setUsageOpen(provider, open) {
+ if (open) usageOpen.add(provider); else usageOpen.delete(provider);
+ try { localStorage.setItem("magpie.usageOpen", JSON.stringify([...usageOpen])); } catch {}
+ renderQuotas();
+}
// The Usage page's cards in the order they were dragged to (settings
// usageOrder, by provider id), as the Agents page's rows are; one it doesn't
@@ -9514,6 +9526,80 @@ function setPanelTab(tab) {
queueMicrotask(panelUseShown);
fit();
}
+// Scroll to a requested card, or resume its highlight after a redraw.
+function flashCard(card, target = card, elapsed = 0) {
+ if (target) {
+ // The native menu-bar click has no DOM event; permit this requested scroll.
+ readerScrolls(1000);
+ target.scrollIntoView({ block: "nearest", behavior: "instant" });
+ }
+ card.classList.remove("flash");
+ void card.offsetWidth; // restart even if this card is still highlighted
+ card.classList.add("flash");
+ const animation = card.getAnimations().find((a) => a.animationName === "flash");
+ if (animation) animation.currentTime = elapsed;
+ card.onanimationend = (e) => {
+ if (e.target !== card || e.animationName !== "flash") return;
+ card.classList.remove("flash");
+ card.onanimationend = null;
+ };
+}
+
+// A focus refresh rebuilds quota cards. Carry the active animation's time
+// to its replacement, preserving scroll position and the remaining duration.
+function keepQuotaFlash(box) {
+ const view = box.closest(".view"), scroll = view?.scrollTop;
+ const flashes = [...box.querySelectorAll(".subscription-card.flash, .pq-card.flash")].flatMap((card) => {
+ const animation = card.getAnimations().find((a) => a.animationName === "flash");
+ if (!animation) return [];
+ const selector = card.matches(".pq-card") ? `.pq-card[data-card="${CSS.escape(card.dataset.card)}"]`
+ : `.subscription-card[data-provider="${CSS.escape(card.dataset.provider)}"]`;
+ return [{ selector, elapsed: animation.currentTime || 0 }];
+ });
+ return () => {
+ for (const { selector, elapsed } of flashes) {
+ const card = box.querySelector(selector);
+ if (card) flashCard(card, null, elapsed);
+ }
+ if (flashes.length && view) {
+ view.scrollTop = scroll;
+ readerLeaves(view);
+ }
+ };
+}
+
+// Keep the request until quota data has produced the target's DOM node.
+function focusQuotaCard() {
+ if (!quotaFocus) return;
+ if (performance.now() > quotaFocusUntil) { quotaFocus = ""; return; }
+ const box = mode === "panel" ? $("#panelQuota") : $("#subscriptionUsage");
+ if (mode === "panel") setPanelTab("usage");
+ else if (view !== "usage" || usageTab !== "usage") return;
+ const id = CSS.escape(quotaFocus);
+ const target = box.querySelector(`[data-card="${id}"]`) || box.querySelector(`[data-provider="${id}"]`);
+ // The panel omits folded accounts; the main window renders them hidden.
+ const quota = quotas?.find((q) => trayCardID(q) === quotaFocus);
+ if (quota?.user && !usageOpen.has(quota.provider) && (!target || target.hidden)) {
+ setUsageOpen(quota.provider, true);
+ return; // the redraw queues focus once the account is visible
+ }
+ if (!target) {
+ if (quotas && !quotasLoading) quotaFocus = "";
+ return;
+ }
+ quotaFocus = "";
+ flashCard(target.closest(".subscription-card, .pq-card"), target);
+}
+
+// Go calls this when a menu-bar quota cell opens the panel.
+function panelQuotaFocus(id) {
+ if (mode !== "panel") return;
+ quotaFocus = id;
+ quotaFocusUntil = performance.now() + 5000;
+ setPanelTab("usage");
+ requestAnimationFrame(focusQuotaCard);
+}
+
if (mode === "panel") {
const tabs = $("#ptabs");
tabs.hidden = false;
@@ -9738,8 +9824,9 @@ function renderPanelQuota() {
}
if (none && panelTab === "usage") setPanelTab("agents");
box.hidden = none;
+ const restoreFlash = keepQuotaFlash(box);
box.replaceChildren();
- if (none) { fit(); return; }
+ if (none) { quotaFocus = ""; fit(); return; }
if (!quotas) {
for (let i = 0; i < 2; i++) {
const card = el("div", "pq-card");
@@ -9795,6 +9882,7 @@ function renderPanelQuota() {
const grid = el("div", "pq-bals");
for (const q of bals) {
const card = el("div", "pq-card bal");
+ card.dataset.card = trayCardID(q);
card.title = [q.name, q.user].filter(Boolean).join(" · ");
// whose balance, at a glance: the provider's logo before its name
const who = el("span", "pq-sub pq-bn");
@@ -9827,8 +9915,10 @@ function renderPanelQuota() {
g.append(grid);
box.append(g);
}
+ restoreFlash();
panelAge();
fit();
+ requestAnimationFrame(focusQuotaCard);
}
// asOfText: an allowance standing in for one that couldn't be read just
@@ -9848,6 +9938,7 @@ function shortWindow(name) {
function panelQuotaCard(q) {
const card = el("div", "pq-card");
+ card.dataset.card = trayCardID(q);
card.append(el("span", "pq-user", q.user || q.name));
card.title = [q.name, q.user, q.plan, q.until ? planTerm(q) : "", q.balance && t("Balance") + " " + q.balance].filter(Boolean).join(" · ");
if (q.error) {
@@ -11188,6 +11279,8 @@ function renderLedger() {
const USAGE_TABS = [["usage", "Overview"], ["requests", "Requests"], ["sessions", "Sessions"]];
let usageTab = "usage";
+let quotaFocus = ""; // pending provider/account card requested by the menu bar
+let quotaFocusUntil = 0; // expire before a late quota response can move the reader
try { const k = localStorage.getItem("magpie.usageTab"); if (USAGE_TABS.some(([id]) => id === k)) usageTab = k; } catch {}
let sessions = null; // { sessions, terminal, dirs }
let sessAgent = "all";
@@ -14057,7 +14150,8 @@ function savePrefs(body) {
// (from a load, a timer, a helper that other clicks share) it is refused,
// and whatever scroll follows is put back.
let purposeUntil = 0, held = null;
-const readerScrolls = (ms) => { purposeUntil = Math.max(purposeUntil, performance.now() + ms); held = null; };
+// A new purposeful scroll supersedes any still-pending quota navigation.
+const readerScrolls = (ms) => { quotaFocus = ""; purposeUntil = Math.max(purposeUntil, performance.now() + ms); held = null; };
function scrollOnPurpose(e, ms = 1000) {
if (!e?.isTrusted || performance.now() - e.timeStamp > 1000) {
console.warn("magpie: a scroll not asked for by the reader was refused");
@@ -14723,12 +14817,16 @@ if (mode === "window" && params.get("import")) {
if (mode === "window" && params.get("view") === "providers" && params.get("edit")) editing = params.get("edit");
// opened from the tray panel's Usage tab: on the Requests of one provider or agent
if (mode === "window" && params.get("view") === "usage") {
- if (params.get("tab") === "requests") usageTab = "requests";
+ // An explicit destination takes precedence over the remembered tab.
+ const tab = params.get("tab");
+ if (tab === "requests" || tab === "usage") usageTab = tab;
+ quotaFocus = usageTab === "usage" ? (params.get("card") || params.get("provider") || "") : "";
+ quotaFocusUntil = performance.now() + 5000;
ledProvider = params.get("provider") || "";
ledAgent = params.get("agent") || "";
ledComputer = params.get("computer") || "";
const u = new URL(location.href);
- for (const k of ["tab", "provider", "agent", "computer"]) u.searchParams.delete(k);
+ for (const k of ["tab", "provider", "agent", "computer", "card"]) u.searchParams.delete(k);
history.replaceState(null, "", u);
}
if (mode === "window" && ["providers", "gateway", "routing", "usage", "sessions", "library", "plugins", "settings"].includes(params.get("view"))) show(params.get("view"));
diff --git a/internal/gui/showmain_url_test.go b/internal/gui/showmain_url_test.go
index 1ea4b2a2..b99ae0e9 100644
--- a/internal/gui/showmain_url_test.go
+++ b/internal/gui/showmain_url_test.go
@@ -16,6 +16,8 @@ func TestMainURL(t *testing.T) {
{mainView(url.Values{"view": {"routing"}, "req": {"42"}}), "/?view=routing&req=42&lang=en"},
{mainView(url.Values{"view": {"settings"}}), "/?view=settings&lang=en"},
{mainView(url.Values{"view": {"usage"}, "tab": {"requests"}, "provider": {"relay team"}, "agent": {"claude-desktop"}}), "/?view=usage&tab=requests&provider=relay+team&agent=claude-desktop&lang=en"},
+ {mainView(url.Values{"view": {"usage"}, "tab": {"usage"}, "provider": {"codex"}}), "/?view=usage&tab=usage&provider=codex&lang=en"},
+ {mainView(url.Values{"view": {"usage"}, "tab": {"usage"}, "card": {"codex|a+b&c@test"}}), "/?view=usage&tab=usage&card=codex%7Ca%2Bb%26c%40test&lang=en"},
{argView("settings"), "/?view=settings&lang=en"},
{argView("usage&tab=x"), "/?view=usage%26tab%3Dx&lang=en"},
} {
@@ -32,3 +34,18 @@ func TestMainURL(t *testing.T) {
}
}
}
+
+// Quota destinations validate provider names and escape account IDs.
+func TestQuotaView(t *testing.T) {
+ for _, c := range []struct{ id, want string }{
+ {"codex", "usage&tab=usage&provider=codex"},
+ {"relay team", "usage&tab=usage&provider=relay+team"},
+ {"codex|a+b@例子.test", "usage&tab=usage&provider=codex&card=codex%7Ca%2Bb%40%E4%BE%8B%E5%AD%90.test"},
+ {"bad&name", "usage&tab=usage"},
+ {"", "usage&tab=usage"},
+ } {
+ if got := quotaView(c.id); got != c.want {
+ t.Errorf("quotaView(%q): %q, want %q", c.id, got, c.want)
+ }
+ }
+}
diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md
index 657d934e..5b466ba4 100644
--- a/internal/gui/tests/README.md
+++ b/internal/gui/tests/README.md
@@ -449,6 +449,25 @@ Off posts `trayNoLogos: true`, a save of another setting keeps it, On posts it
back; the row has no left-border stripe, no click moves the page, and there is
no row with no card picked nor off a Mac; in English and Chinese.
+`tray-cell-click.test.cjs` checks menu-bar quota navigation in the panel
+and main window: selecting Allowances/Overview, scrolling to and highlighting
+the matching account or balance, and keeping a click pending while quotas
+load. It checks that the panel loads Wails so native ExecJS can run.
+A focus-triggered redraw preserves the remaining highlight animation in both
+panel and main window. It expands and remembers folded target accounts,
+disables panel-header dragging,
+and cancels navigation after five seconds or a purposeful user scroll.
+It runs in English and Chinese on Chromium and WebKit, with a mocked API. Run with
+`node --test internal/gui/tests/tray-cell-click.test.cjs`.
+
+`TestTrayCellClickReleasedPanel` (darwin, cgo, GUI) runs a separate AppKit
+process with an isolated config and a minimal page: a quota click recreates
+the released panel, queued ExecJS selects the account once Wails is ready,
+the next click closes it, and another release can be reopened and focused.
+It requires a macOS graphical session and does not start the magpie backend
+or create a status item. Run with
+`go test ./internal/gui -run '^TestTrayCellClickReleasedPanel$' -count=1`.
+
`text-size.test.cjs` checks the Settings page's Text size row (100, 110,
125 and 150%): a pick posts to /api/settings/text-size without scrolling
the page, stays picked after a reload (boot.js carries it, and the page's
diff --git a/internal/gui/tests/tray-cell-click.test.cjs b/internal/gui/tests/tray-cell-click.test.cjs
new file mode 100644
index 00000000..db8c55a4
--- /dev/null
+++ b/internal/gui/tests/tray-cell-click.test.cjs
@@ -0,0 +1,212 @@
+// Run with Node's test runner and Playwright on the module path; see README.md.
+// Native ExecJS and main-window URLs select the requested allowance/account,
+// including delayed data, focus refreshes, real scrolling and repeated openings. APIs are mocked.
+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 quotas = (empty) => empty ? [] : [
+ { provider: "claude", name: "Claude Code", icon: "claude-color", user: "a@b.c", windows: [{ name: "Weekly", used: 17.6 }, { name: "5-hour", used: 42.2 }] },
+ { provider: "codex", name: "Codex", icon: "codex-color", user: "x@y.z", windows: [{ name: "5-hour", used: 8 }, { name: "Weekly", used: 100 }] },
+ ...Array.from({ length: 6 }, (_, i) => ({ provider: "codex", name: "Codex", icon: "codex-color", user: `other-${i}@例子.test`, windows: [{ name: "Weekly", used: 25 }] })),
+ { provider: "deepseek", name: "DeepSeek", icon: "deepseek-color", windows: [], balance: "¥12.30" },
+ { provider: "kimi", name: "Kimi", icon: "kimi-color", windows: [{ name: "Weekly", used: 30 }] },
+];
+
+function serve(lang, empty, ready) {
+ const settings = { theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd" };
+ 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: "window.__runtimeReady = true; export const Window = {};" });
+ if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings });
+ if (url.pathname === "/api/settings") return json(settings);
+ if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
+ if (url.pathname === "/api/usage/quotas") { await ready; return json(quotas(empty)); }
+ if (url.pathname === "/api/usage") return json({ days: [] });
+ if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
+ if (url.pathname === "/api/plugins") return json({ plugins: [] });
+ 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: "" }); }
+ };
+}
+
+for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
+ for (const lang of ["en", "zh"]) {
+ test(`${engine} ${lang}: a menu-bar cell's click opens the panel's Allowances tab at that agent's card`, async (t) => {
+ const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
+ t.after(async () => { await browser.close(); });
+ const errors = [];
+ const open = async (url, viewport, init, empty, ready) => {
+ // no reduced-motion emulation here: it makes every animation a
+ // frame long, and the flash the cards light up with is one
+ const context = await browser.newContext({ viewport });
+ // what a click lands on is said by its scrolling there
+ await context.addInitScript(() => {
+ window.__scrolled = [];
+ const scroll = Element.prototype.scrollIntoView;
+ Element.prototype.scrollIntoView = function (options) { window.__scrolled.push(this.dataset.card || this.dataset.provider || this.tagName); scroll.call(this, options); };
+ });
+ for (const script of init || []) await context.addInitScript(script);
+ const page = await context.newPage();
+ page.setDefaultTimeout(5000);
+ page.on("pageerror", (e) => errors.push(e.message));
+ await page.route("**/*", serve(lang, empty, ready));
+ await page.goto(url);
+ return page;
+ };
+
+ const visible = async (page, selector) => {
+ await page.waitForFunction((selector) => {
+ const e = document.querySelector(selector);
+ if (!e) return false;
+ const r = e.getBoundingClientRect(), v = e.closest(".view").getBoundingClientRect();
+ return r.top >= v.top - 1 && r.bottom <= v.bottom + 1;
+ }, selector).catch(async (err) => {
+ const info = await page.locator(selector).evaluate((e) => {
+ const v = e.closest(".view");
+ return { target: e.getBoundingClientRect().toJSON(), view: v.getBoundingClientRect().toJSON(), scroll: v.scrollTop, max: v.scrollHeight - v.clientHeight, viewport: innerHeight };
+ });
+ throw new Error(err.message + " " + JSON.stringify(info));
+ });
+ };
+
+ // the panel: Go's ExecJS runs panelQuotaFocus, the global Go names —
+ // a click on the Codex cell is the Allowances tab at Codex's card
+ const panel = await open("http://magpie.test/?mode=panel", { width: 440, height: 640 });
+ await panel.waitForFunction(() => document.querySelectorAll("#panelQuota .pq-card").length >= 4);
+ await panel.waitForFunction(() => window.__runtimeReady === true); // native ExecJS can now drain
+ assert.equal(await panel.locator("header.top").evaluate((e) => getComputedStyle(e).getPropertyValue("--wails-draggable").trim()), "no-drag", "the runtime must not make the panel draggable");
+ assert.equal(await panel.evaluate(() => typeof panelQuotaFocus), "function", "panelQuotaFocus is Go's to call by name");
+ assert.equal(await panel.evaluate(() => document.body.dataset.ptab), "agents", "the panel starts on its agents");
+ await panel.evaluate(() => panelQuotaFocus("codex|x@y.z"));
+ await panel.waitForFunction(() => document.body.dataset.ptab === "usage");
+ assert(await panel.locator('#ptabs [data-ptab="usage"]').evaluate((e) => e.classList.contains("on")), "the Allowances tab is the one picked");
+ assert.equal(await panel.evaluate(() => localStorage.getItem("magpie.panelTab")), "usage");
+ await panel.waitForFunction(() => document.querySelector('#panelQuota .pq-card[data-card="codex|x@y.z"]')?.classList.contains("flash"));
+ assert.deepEqual(await panel.evaluate(() => window.__scrolled.slice(-1)), ["codex|x@y.z"], "the card is scrolled to");
+ assert(await panel.locator('#panelQuota [data-card="codex|x@y.z"]').evaluate((card) => {
+ flashCard(card); // repeated focus restarts the same card
+ card.firstElementChild.dispatchEvent(new AnimationEvent("animationend", { bubbles: true, animationName: "flash" }));
+ card.dispatchEvent(new AnimationEvent("animationend", { animationName: "unrelated" }));
+ return card.classList.contains("flash");
+ }), "child or unrelated animations must not end the card's highlight");
+ // Showing a native panel gives it focus, which refreshes its state and quotas.
+ // The first click must remain highlighted across that refresh.
+ await panel.evaluate(() => {
+ window.__beforeFocusRefresh = document.querySelector('#panelQuota [data-card="codex|x@y.z"]');
+ window.__beforeFocusRefresh.getAnimations().find((a) => a.animationName === "flash").currentTime = 600;
+ window.dispatchEvent(new Event("focus"));
+ });
+ await panel.waitForFunction(() => document.querySelector('#panelQuota [data-card="codex|x@y.z"]') !== window.__beforeFocusRefresh);
+ assert(await panel.locator('#panelQuota [data-card="codex|x@y.z"]').evaluate((e) => e.classList.contains("flash")), "first-open focus refresh must preserve the highlight");
+
+ assert(await panel.locator('#panelQuota [data-card="codex|x@y.z"]').evaluate((e) => e.getAnimations().find((a) => a.animationName === "flash")?.currentTime >= 600), "refresh must continue the animation rather than restart it");
+ await panel.waitForFunction(() => !document.querySelector('#panelQuota [data-card="codex|x@y.z"]').classList.contains("flash"));
+
+ // a balance alone is a cell too, and its card is among the Balances
+ await panel.evaluate(() => panelQuotaFocus("deepseek"));
+ await panel.waitForFunction(() => document.querySelector('#panelQuota .pq-card.bal[data-card="deepseek"]')?.classList.contains("flash"));
+ assert.deepEqual(await panel.evaluate(() => window.__scrolled.slice(-1)), ["deepseek"]);
+ await visible(panel, '#panelQuota [data-card="deepseek"]');
+ // a card that isn't there picks the tab and stops: nothing flashed,
+ // nothing broken
+ await panel.evaluate(() => panelQuotaFocus("nobody"));
+ await panel.waitForFunction(() => !document.querySelector('#panelQuota .pq-card[data-card="deepseek"]')?.classList.contains("flash"));
+ assert.equal(await panel.evaluate(() => document.body.dataset.ptab), "usage");
+ assert.equal(await panel.evaluate(() => window.__scrolled.filter((s) => s === "nobody").length), 0);
+
+ // A click before the first quota response must survive the loading cards.
+ let release;
+ const ready = new Promise((resolve) => { release = resolve; });
+ const slow = await open("http://magpie.test/?mode=panel", { width: 440, height: 300 }, [], false, ready);
+ await slow.waitForFunction(() => typeof panelQuotaFocus === "function");
+ await slow.evaluate(() => panelQuotaFocus("kimi"));
+ await slow.evaluate(() => new Promise(requestAnimationFrame));
+ release();
+ await slow.waitForFunction(() => document.querySelector('#panelQuota [data-card="kimi"]')?.classList.contains("flash"));
+
+ await visible(slow, '#panelQuota [data-card="kimi"]');
+ await slow.waitForFunction(() => !document.querySelector('#panelQuota [data-card="kimi"]').classList.contains("flash"));
+ await visible(slow, '#panelQuota [data-card="kimi"]');
+
+ await panel.emulateMedia({ reducedMotion: "reduce" });
+ assert.equal(await panel.locator('#panelQuota [data-card="codex|other-5@例子.test"]').count(), 0, "the requested account starts folded");
+ await panel.evaluate(() => panelQuotaFocus("codex|other-5@例子.test"));
+ await panel.waitForFunction(() => window.__scrolled.at(-1) === "codex|other-5@例子.test");
+ await visible(panel, '#panelQuota [data-card="codex|other-5@例子.test"]');
+ assert.deepEqual(await panel.evaluate(() => JSON.parse(localStorage.getItem("magpie.usageOpen"))), ["codex"], "navigation remembers the expanded provider");
+ await panel.waitForFunction(() => !document.querySelector('#panelQuota [data-card="codex|other-5@例子.test"]').classList.contains("flash"));
+
+ // no allowances to show: no tab to pick, the panel keeps its agents
+ const none = await open("http://magpie.test/?mode=panel", { width: 440, height: 640 }, [], true);
+ await none.waitForFunction(() => document.querySelectorAll("#agents").length === 1);
+ await none.evaluate(() => panelQuotaFocus("codex|x@y.z"));
+ await none.waitForFunction(() => document.body.dataset.ptab === "agents");
+
+ // the window: opened on its allowances (view=usage&tab=usage), the
+ // Overview is the pane whatever the page was last left on, at the
+ // provider's card
+ const win = await open("http://magpie.test/?view=usage&tab=usage&provider=kimi", { width: 900, height: 700 }, [
+ () => { try { localStorage.setItem("magpie.usageTab", "requests"); } catch {} },
+ ]);
+ await win.waitForFunction(() => !document.querySelector("#usagePane").hidden);
+ assert.equal(await win.evaluate(() => document.querySelector("#ledgerPane").hidden), true, "the Requests the page was left on are not the pane");
+ await win.waitForFunction(() => document.querySelector('.subscription-card[data-provider="kimi"]')?.classList.contains("flash"));
+ assert.deepEqual(await win.evaluate(() => window.__scrolled.slice(-1)), ["kimi"], "the provider's card is scrolled to");
+ await win.evaluate(async () => {
+ document.querySelector('.subscription-card[data-provider="kimi"]').getAnimations().find((a) => a.animationName === "flash").currentTime = 600;
+ await loadQuotas();
+ });
+ assert(await win.locator('.subscription-card[data-provider="kimi"]').evaluate((e) => e.classList.contains("flash") && e.getAnimations().find((a) => a.animationName === "flash")?.currentTime >= 600), "main-window quota refresh must preserve the remaining highlight");
+ assert.equal(await win.evaluate(() => location.search.includes("tab=")), false, "the parameters are the page's own again");
+ await visible(win, '.subscription-card[data-provider="kimi"]');
+
+ // Same vendor, a different account: scroll within the tall vendor card.
+ const id = "codex|other-5@例子.test";
+ const account = await open("http://magpie.test/?view=usage&tab=usage&provider=codex&card=" + encodeURIComponent(id), { width: 900, height: 500 });
+ await account.waitForFunction(() => document.querySelector('.subscription-card[data-provider="codex"]')?.classList.contains("flash"));
+ assert.deepEqual(await account.evaluate(() => window.__scrolled.slice(-1)), [id]);
+ await visible(account, '.subscription-account[data-card="codex|other-5@例子.test"]');
+ assert.deepEqual(await account.evaluate(() => JSON.parse(localStorage.getItem("magpie.usageOpen"))), ["codex"]);
+ assert.equal(await account.evaluate(() => location.search.includes("card=")), false);
+ await account.waitForFunction(() => !document.querySelector('.subscription-card[data-provider="codex"]').classList.contains("flash"));
+ await visible(account, '.subscription-account[data-card="codex|other-5@例子.test"]');
+
+ // A slow response must not pull the reader back after the request expires.
+ let finish;
+ const expired = await open("http://magpie.test/?mode=panel", { width: 440, height: 300 }, [], false, new Promise((resolve) => { finish = resolve; }));
+ await expired.waitForFunction(() => typeof panelQuotaFocus === "function");
+ await expired.clock.install();
+ await expired.evaluate(() => panelQuotaFocus("codex|other-5@例子.test"));
+ await expired.clock.fastForward(6000);
+ finish();
+ await expired.waitForFunction(() => document.querySelector('#panelQuota [data-card="kimi"]'));
+ await expired.evaluate(() => new Promise(requestAnimationFrame));
+ assert.deepEqual(await expired.evaluate(() => window.__scrolled), [], "expired requests cannot scroll or expand accounts");
+ assert.equal(await expired.locator('#panelQuota [data-card="codex|other-5@例子.test"]').count(), 0);
+
+ // A purposeful user scroll supersedes a still-pending menu-bar request.
+ let resume;
+ const scrolled = await open("http://magpie.test/?mode=panel", { width: 440, height: 300 }, [], false, new Promise((resolve) => { resume = resolve; }));
+ await scrolled.waitForFunction(() => typeof panelQuotaFocus === "function");
+ await scrolled.evaluate(() => panelQuotaFocus("kimi"));
+ await scrolled.mouse.move(200, 150);
+ await scrolled.mouse.wheel(0, 200);
+ // Flush input delivery before allowing the data to arrive.
+ await scrolled.waitForFunction(() => !quotaFocus);
+ resume();
+ await scrolled.waitForFunction(() => document.querySelector('#panelQuota [data-card="kimi"]'));
+ await scrolled.evaluate(() => new Promise(requestAnimationFrame));
+ assert.deepEqual(await scrolled.evaluate(() => window.__scrolled), [], "manual scrolling cancels pending navigation");
+
+ assert.deepEqual(errors, []);
+ });
+ }
+}
diff --git a/internal/gui/trace.go b/internal/gui/trace.go
index e0752029..9a3b78a0 100644
--- a/internal/gui/trace.go
+++ b/internal/gui/trace.go
@@ -6,6 +6,7 @@ import (
"net/url"
"regexp"
"strconv"
+ "strings"
"time"
"github.com/yetone/magpie/internal/gateway"
@@ -67,8 +68,9 @@ func pricedRoutes(routes []gateway.Route) []routeJSON {
// mainView is the tab the window is asked to open on, as the page's view
// parameter: the Routing page on one request (req, its id) when the tray
// panel's Routing tab asks for it, or the Usage page's Requests on one
-// provider or agent when its Usage tab does. Anything but an id, or a name
-// of the kind a provider or an agent has, is dropped.
+// provider or agent when its Usage tab does, its Overview (the allowances)
+// when a menu-bar cell's click does. Anything but an id, or a name of the
+// kind a provider or an agent has, is dropped.
func mainView(q url.Values) string {
view := q.Get("view")
switch view {
@@ -77,8 +79,11 @@ func mainView(q url.Values) string {
view += "&req=" + strconv.FormatInt(id, 10)
}
case "usage":
- if q.Get("tab") == "requests" {
- view += "&tab=requests"
+ if tab := q.Get("tab"); tab == "requests" || tab == "usage" {
+ view += "&tab=" + tab
+ }
+ if card := q.Get("card"); q.Get("tab") == "usage" && card != "" {
+ view += "&card=" + url.QueryEscape(card)
}
for _, k := range []string{"provider", "agent"} {
if v := q.Get(k); mainName.MatchString(v) {
@@ -106,6 +111,20 @@ func mainURL(view, query string) string { return "/?view=" + view + query }
// restart to update) as ShowMain takes it: a name, never parameters.
func argView(s string) string { return url.QueryEscape(s) }
+// quotaView opens the Usage overview at a provider/account card.
+// Invalid provider names fall back to the overview without a target.
+func quotaView(id string) string {
+ provider, _, hasAccount := strings.Cut(id, "|")
+ view := "usage&tab=usage"
+ if mainName.MatchString(provider) {
+ view += "&provider=" + url.QueryEscape(provider)
+ if hasAccount {
+ view += "&card=" + url.QueryEscape(id)
+ }
+ }
+ return view
+}
+
// traceRoutes serves the routing trace for the Gateway view to play: it
// waits up to 25 s for something to change after the seq it is given, so
// the page hears of a request as it happens.
diff --git a/internal/gui/trayimage_click_darwin.go b/internal/gui/trayimage_click_darwin.go
new file mode 100644
index 00000000..ba4c1bf6
--- /dev/null
+++ b/internal/gui/trayimage_click_darwin.go
@@ -0,0 +1,15 @@
+//go:build darwin && cgo
+
+package gui
+
+import "C"
+
+// The export lives separately because its cgo preamble must not define functions.
+// Copy the native string before returning; a refresh can replace the drawn cells.
+//
+//export mpCellClicked
+func mpCellClicked(id *C.char) {
+ if onTrayCellClick != nil {
+ onTrayCellClick(C.GoString(id)) // the handler schedules work after startup
+ }
+}
diff --git a/internal/gui/trayimage_darwin.go b/internal/gui/trayimage_darwin.go
index 61a4c69f..8182842e 100644
--- a/internal/gui/trayimage_darwin.go
+++ b/internal/gui/trayimage_darwin.go
@@ -13,6 +13,7 @@ typedef struct {
int iconLen;
int mono;
int plain; // no logo: the rows alone, a thin line before
+ const char *id;
const char *letter;
const char *rows; // one or two, a newline between
} mpTrayCell;
@@ -41,6 +42,7 @@ static NSArray *mpCells(mpTrayCell *cells, int n) {
if (im != nil && im.representations.count > 0) c[@"icon"] = im;
[im release];
}
+ c[@"id"] = [NSString stringWithUTF8String:cells[i].id ? cells[i].id : ""];
c[@"mono"] = @(cells[i].mono != 0);
c[@"plain"] = @(cells[i].plain != 0);
c[@"letter"] = [NSString stringWithUTF8String:cells[i].letter ? cells[i].letter : ""];
@@ -234,6 +236,40 @@ static NSImage *mpImage(NSArray *cells, NSImage *bird, CGFloat h) {
return im;
}
+// mpStarts writes each cell's start in image points, following mpDraw's layout.
+// Plain cells start at their separator; others start at their logo.
+static void mpStarts(NSArray *cells, CGFloat h, CGFloat *out) {
+ CGFloat pos = h + mpBirdGap;
+ BOOL first = YES;
+ int i = 0;
+ for (NSDictionary *c in cells) {
+ if ([c[@"plain"] boolValue]) {
+ out[i] = first ? pos : pos + mpSepGap;
+ pos += mpLead(c, first);
+ } else {
+ pos += mpLead(c, first) - mpLogo - mpLogoGap;
+ out[i] = pos;
+ pos += mpLogo + mpLogoGap;
+ }
+ pos += mpColumn(c);
+ i++;
+ first = NO;
+ }
+}
+
+// mpCellAt returns the cell at x, including its trailing gap.
+// The bird and points outside the image return -1.
+static int mpCellAt(NSArray *cells, CGFloat h, CGFloat x, CGFloat w) {
+ if (cells.count == 0 || x < h + mpBirdGap || x >= w) return -1;
+ int n = (int)cells.count;
+ CGFloat starts[n];
+ mpStarts(cells, h, starts);
+ for (int i = 0; i < n; i++) {
+ if (x < (i + 1 < n ? starts[i + 1] : w)) return i;
+ }
+ return -1;
+}
+
static NSStatusBarButton *mpFind(NSView *v) {
if ([v isKindOfClass:[NSStatusBarButton class]]) return (NSStatusBarButton *)v;
for (NSView *s in v.subviews) {
@@ -256,12 +292,29 @@ static NSStatusBarButton *mpButton(void) {
static NSArray *mpShown;
static NSImage *mpBird;
+// mpCellClicked copies the drawn card's identity to Go before it can be replaced.
+extern void mpCellClicked(char *id);
+
+static CGFloat mpHitW = 0;
+
+// Used by the single status-item monitor and native tests. Modified clicks
+// retain the button's normal action; an unmodified quota click uses its ID.
+static BOOL mpClickCell(NSArray *cells, CGFloat h, CGFloat x, CGFloat w, NSEventModifierFlags flags) {
+ if (flags & (NSEventModifierFlagCommand | NSEventModifierFlagControl | NSEventModifierFlagOption | NSEventModifierFlagShift)) return NO;
+ int i = mpCellAt(cells, h, x, w);
+ if (i < 0) return NO;
+ mpCellClicked((char *)[cells[i][@"id"] UTF8String]);
+ return YES;
+}
+
static void mpApply(void) {
NSStatusBarButton *b = mpButton();
if (b == nil || mpShown == nil) return;
b.title = @"";
- b.image = mpImage(mpShown, mpBird, [[NSStatusBar systemStatusBar] thickness]);
+ NSImage *im = mpImage(mpShown, mpBird, [[NSStatusBar systemStatusBar] thickness]);
+ b.image = im;
b.imagePosition = NSImageOnly;
+ mpHitW = im.size.width;
}
static NSImage *mpBirdImage(const void *bird, int len) {
@@ -317,6 +370,7 @@ static void mpHide(void) {
dispatch_async(dispatch_get_main_queue(), ^{
[mpShown release];
mpShown = nil;
+ mpHitW = 0; // every click is the item's whole one again
});
}
@@ -338,6 +392,12 @@ static void mpOwnClicks(void) {
[NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskLeftMouseDown handler:^NSEvent *(NSEvent *e) {
NSStatusBarButton *b = mpButton();
if (b == nil || e.window != b.window) return e;
+ NSPoint p = [b convertPoint:e.locationInWindow fromView:nil];
+ if (!NSPointInRect(p, b.bounds)) return e;
+ // Route quota clicks before the generic action, within this
+ // one monitor; local monitor ordering is not guaranteed.
+ CGFloat x = p.x - (b.bounds.size.width - mpHitW) / 2;
+ if (mpClickCell(mpShown, [[NSStatusBar systemStatusBar] thickness], x, mpHitW, e.modifierFlags)) return nil;
[NSApp sendAction:b.action to:b.target from:b];
return nil;
}];
@@ -383,6 +443,33 @@ static void *mpPNG(mpTrayCell *cells, int n, const void *bird, int len, CGFloat
}
return out;
}
+
+// Native hit-testing helpers for Go tests, using the actual image layout.
+static int mpCellAtCells(mpTrayCell *cells, int n, CGFloat h, CGFloat x) {
+ @autoreleasepool {
+ NSArray *cs = mpCells(cells, n);
+ return mpCellAt(cs, h, x, mpWidth(cs, h));
+ }
+}
+
+static CGFloat mpCellStartAt(mpTrayCell *cells, int n, CGFloat h, int i) {
+ @autoreleasepool {
+ NSArray *cs = mpCells(cells, n);
+ if (i < 0 || i >= n) return -1;
+ CGFloat starts[n];
+ mpStarts(cs, h, starts);
+ return starts[i];
+ }
+}
+
+// Exercise the same identity lookup and C-to-Go callback without a live status item.
+static int mpClickCellsAt(mpTrayCell *cells, int n, CGFloat h, CGFloat x, unsigned long flags) {
+ @autoreleasepool {
+ NSArray *cs = mpCells(cells, n);
+ return mpClickCell(cs, h, x, mpWidth(cs, h), flags);
+ }
+}
+
*/
import "C"
@@ -406,8 +493,8 @@ func withCells(cells []trayCell, f func(*C.mpTrayCell, C.int)) {
cs := (*[1 << 16]C.mpTrayCell)(C.malloc(C.size_t(len(cells)) * C.size_t(unsafe.Sizeof(C.mpTrayCell{}))))[:len(cells):len(cells)]
var owned []unsafe.Pointer
for i, c := range cells {
- cs[i] = C.mpTrayCell{letter: C.CString(c.Letter), rows: C.CString(strings.Join(c.Rows, "\n"))}
- owned = append(owned, unsafe.Pointer(cs[i].letter), unsafe.Pointer(cs[i].rows))
+ cs[i] = C.mpTrayCell{id: C.CString(c.ID), letter: C.CString(c.Letter), rows: C.CString(strings.Join(c.Rows, "\n"))}
+ owned = append(owned, unsafe.Pointer(cs[i].id), unsafe.Pointer(cs[i].letter), unsafe.Pointer(cs[i].rows))
if len(c.Icon) > 0 {
cs[i].icon, cs[i].iconLen = C.CBytes(c.Icon), C.int(len(c.Icon))
owned = append(owned, cs[i].icon)
@@ -486,3 +573,31 @@ func cBool(b bool) C.int {
}
return 0
}
+
+// These native test wrappers stay here because Go does not support cgo
+// imports in _test.go files. They do not install monitors or create windows.
+// trayImageCellAt tests the native hit map; -1 means no quota cell.
+func trayImageCellAt(cells []trayCell, h, x float64) int {
+ at := -1
+ withCells(cells, func(cs *C.mpTrayCell, count C.int) {
+ at = int(C.mpCellAtCells(cs, count, C.CGFloat(h), C.CGFloat(x)))
+ })
+ return at
+}
+
+// trayImageCellStart returns cell i's start in image points, or -1 if absent.
+func trayImageCellStart(cells []trayCell, h float64, i int) float64 {
+ s := -1.0
+ withCells(cells, func(cs *C.mpTrayCell, count C.int) {
+ s = float64(C.mpCellStartAt(cs, count, C.CGFloat(h), C.int(i)))
+ })
+ return s
+}
+
+func trayImageClickAt(cells []trayCell, h, x float64, modifiers uint64) bool {
+ hit := false
+ withCells(cells, func(cs *C.mpTrayCell, count C.int) {
+ hit = C.mpClickCellsAt(cs, count, C.CGFloat(h), C.CGFloat(x), C.ulong(modifiers)) != 0
+ })
+ return hit
+}
diff --git a/internal/gui/trayimage_darwin_test.go b/internal/gui/trayimage_darwin_test.go
index 5cd890ae..202c55b2 100644
--- a/internal/gui/trayimage_darwin_test.go
+++ b/internal/gui/trayimage_darwin_test.go
@@ -82,6 +82,63 @@ func TestTrayImage(t *testing.T) {
}
}
+// Hit-testing follows the image layout for both logo and plain cells.
+func TestTrayImageCellAt(t *testing.T) {
+ bird, err := os.ReadFile("tray.png")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cells, _, _ := trayUsageView(trayCards(), time.Now(), false)
+ const h = 22.0
+ _, w, _ := trayImagePNG(cells, bird, h, 2, false, false)
+ width := float64(w) / 2
+ if at := trayImageCellAt(cells, h, 5); at != -1 {
+ t.Errorf("a click by the bird says cell %d", at)
+ }
+ if at := trayImageCellAt(cells, h, width-0.5); at != len(cells)-1 {
+ t.Errorf("a click at the image's end says cell %d, want %d", at, len(cells)-1)
+ }
+ if at := trayImageCellAt(cells, h, width+1); at != -1 {
+ t.Errorf("a click past the image says cell %d", at)
+ }
+ if at := trayImageCellAt(nil, h, 5); at != -1 {
+ t.Errorf("no cells: cell %d", at)
+ }
+ from := make([]float64, len(cells))
+ for i := range cells {
+ from[i] = trayImageCellStart(cells, h, i)
+ if from[i] <= h || from[i] >= width {
+ t.Fatalf("cell %d begins at %g of %g", i, from[i], width)
+ }
+ if i > 0 && from[i] <= from[i-1] {
+ t.Fatalf("cell %d begins at %g, before the one before it at %g", i, from[i], from[i-1])
+ }
+ }
+ for i := range cells {
+ if at := trayImageCellAt(cells, h, from[i]+0.5); at != i {
+ t.Errorf("a click at %g (cell %d's start) says cell %d", from[i], i, at)
+ }
+ if i+1 < len(cells) {
+ if at := trayImageCellAt(cells, h, from[i+1]-0.5); at != i {
+ t.Errorf("a click at %g (before cell %d) says cell %d", from[i+1]-0.5, i+1, at)
+ }
+ }
+ }
+ if s := trayImageCellStart(cells, h, len(cells)); s != -1 {
+ t.Errorf("a cell past them all begins at %g", s)
+ }
+ // logos off (Settings): the same walk, the digits alone
+ plain := trayPlain(cells)
+ for i := range plain {
+ if s := trayImageCellStart(plain, h, i); s <= h || s >= width {
+ t.Errorf("plain cell %d begins at %g of %g", i, s, width)
+ }
+ }
+ if at := trayImageCellAt(plain, h, trayImageCellStart(plain, h, 0)+0.5); at != 0 {
+ t.Errorf("the first plain cell's click says cell %d", at)
+ }
+}
+
// inkIn counts the pixels in r drawn in the bar's text colour.
func inkIn(img image.Image, r image.Rectangle, dark bool) int {
n := 0
@@ -164,3 +221,38 @@ func TestTrayImageLogoSizes(t *testing.T) {
}
}
}
+
+// The native callback must carry the account ID before the drawing is freed
+// or a refresh changes the order, including the logo-free layout.
+func TestTrayImageClickIdentity(t *testing.T) {
+ prior := onTrayCellClick
+ t.Cleanup(func() { onTrayCellClick = prior })
+ clicked := make(chan string, 1)
+ onTrayCellClick = func(id string) { clicked <- id }
+ cards := trayCards()
+ cards[1].User = "a\"b\\c@例子.test"
+ cells, _, _ := trayUsageView(cards, time.Now(), false)
+ for _, layout := range [][]trayCell{cells, trayPlain(cells)} {
+ if trayImageClickAt(layout, 22, 5, 0) {
+ t.Fatal("bird intercepted")
+ }
+ x := trayImageCellStart(layout, 22, 1) + 1
+ // AppKit's Shift, Control, Option and Command modifier bits.
+ for _, modifiers := range []uint64{1 << 17, 1 << 18, 1 << 19, 1 << 20} {
+ if trayImageClickAt(layout, 22, x, modifiers) {
+ t.Fatal("modified click intercepted")
+ }
+ }
+ if !trayImageClickAt(layout, 22, x, 0) {
+ t.Fatal("cell missed")
+ }
+ select {
+ case id := <-clicked:
+ if id != trayCardID(cards[1]) {
+ t.Fatalf("clicked %q", id)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("native click callback lost")
+ }
+ }
+}
diff --git a/internal/gui/trayusage.go b/internal/gui/trayusage.go
index ddeb9560..20782768 100644
--- a/internal/gui/trayusage.go
+++ b/internal/gui/trayusage.go
@@ -3,6 +3,7 @@ package gui
import (
"cmp"
"context"
+ "encoding/json"
"fmt"
"math"
"regexp"
@@ -200,6 +201,7 @@ func trayInUseCard(cards []provider.SubscriptionQuota, pid string) (provider.Sub
// label's parts stacked, the shortest window over the longer ("42%" over
// "18%"), or its balance alone.
type trayCell struct {
+ ID string // stable provider/account identity, captured with the drawn cell
Icon []byte // the logo as the Usage page has it (SVG or PNG); nil for none
Mono bool // a black glyph, drawn in the menu bar's text colour
Letter string // drawn in its place when there is no logo, or it can't be read
@@ -212,15 +214,14 @@ type trayCell struct {
func trayPlain(cells []trayCell) []trayCell {
out := make([]trayCell, len(cells))
for i, c := range cells {
- out[i] = trayCell{Plain: true, Rows: c.Rows}
+ out[i] = trayCell{ID: c.ID, Plain: true, Rows: c.Rows}
}
return out
}
// trayUsageView is what the tray shows for the cards: a cell for each
-// that has a label; the labels as one line of text, " | " between cards,
-// for a tray that shows text and not the cells; and the tooltip spelling
-// each out.
+// that has a label, with its stable identity for clicks; labels joined by
+// " | " for a tray that shows text; and a tooltip spelling each out.
func trayUsageView(cards []provider.SubscriptionQuota, now time.Time, left bool) (cells []trayCell, label, tip string) {
var labels, tips []string
for _, q := range cards {
@@ -232,7 +233,7 @@ func trayUsageView(cards []provider.SubscriptionQuota, now time.Time, left bool)
continue
}
labels = append(labels, l)
- c := trayCell{Rows: strings.Split(l, " · ")}
+ c := trayCell{ID: trayCardID(q), Rows: strings.Split(l, " · ")}
for i, r := range c.Rows {
c.Rows[i] = trayRow(r)
}
@@ -354,3 +355,13 @@ func svgNumber(d string, i int) int {
// onTrayUsage brings the tray's text up to date at once, when the Settings
// page changes which card it shows; set by the process that has the tray.
var onTrayUsage func()
+
+// onTrayCellClick receives the stable card ID from the native image.
+// Set by the tray process; it must schedule window work without blocking.
+var onTrayCellClick func(id string)
+
+// panelQuotaJS safely quotes the card ID for the panel's focus handler.
+func panelQuotaJS(card string) string {
+ id, _ := json.Marshal(card) // quoted and escaped: a JSON string is a JS one
+ return "panelQuotaFocus(" + string(id) + ")"
+}
diff --git a/internal/gui/trayusage_cells_test.go b/internal/gui/trayusage_cells_test.go
index 159625b1..508481c7 100644
--- a/internal/gui/trayusage_cells_test.go
+++ b/internal/gui/trayusage_cells_test.go
@@ -2,6 +2,7 @@ package gui
import (
"slices"
+ "strconv"
"strings"
"testing"
"time"
@@ -114,6 +115,31 @@ func TestTrayUsageCells(t *testing.T) {
}
}
+// Every rendered cell carries its own identity, even with logos disabled
+// or unreadable cards skipped. JavaScript arguments preserve unusual account IDs.
+func TestTrayCellsCards(t *testing.T) {
+ cards := trayCards()
+ cards[0].User = "a\"b\\c@例子.test"
+ cards[1].Error = "signed out"
+ cells, _, _ := trayUsageView(cards, time.Now(), false)
+ plain := trayPlain(cells)
+ if len(cells) != len(cards)-1 {
+ t.Fatalf("cells: %d", len(cells))
+ }
+ for i, cell := range cells {
+ at := i
+ if i > 0 {
+ at++
+ }
+ if cell.ID != trayCardID(cards[at]) || plain[i].ID != cell.ID {
+ t.Fatalf("cell %d: %q, plain %q", i, cell.ID, plain[i].ID)
+ }
+ if got := panelQuotaJS(cell.ID); got != "panelQuotaFocus("+strconv.Quote(cell.ID)+")" {
+ t.Fatalf("JS: %s", got)
+ }
+ }
+}
+
// An arc's flags run into the number after them in many of the logos
// (Codex's among them); the Mac's SVG drawing needs them apart.
func TestSVGArcFlags(t *testing.T) {
diff --git a/internal/gui/trayusage_panel_darwin_test.go b/internal/gui/trayusage_panel_darwin_test.go
new file mode 100644
index 00000000..a5374325
--- /dev/null
+++ b/internal/gui/trayusage_panel_darwin_test.go
@@ -0,0 +1,97 @@
+//go:build darwin && cgo && !nogui
+
+package gui
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "os"
+ "os/exec"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+ "github.com/wailsapp/wails/v3/pkg/events"
+)
+
+// A separate process runs AppKit on its main thread. It uses an isolated
+// config and a minimal page, without starting magpie's backend or status item.
+func TestTrayCellClickReleasedPanel(t *testing.T) {
+ exe, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, exe)
+ cmd.Env = append(os.Environ(), "MAGPIE_TEST_TRAY_PANEL=1", "XDG_CONFIG_HOME="+t.TempDir())
+ out, err := cmd.CombinedOutput()
+ if err != nil || !strings.Contains(string(out), "tray quota: recreated and focused twice") {
+ t.Fatalf("released panel: %v\n%s", err, out)
+ }
+}
+
+func init() {
+ if os.Getenv("MAGPIE_TEST_TRAY_PANEL") != "1" {
+ return
+ }
+ seen := make(chan string, 2)
+ app := application.New(application.Options{
+ Name: "magpie tray regression",
+ Mac: application.MacOptions{ActivationPolicy: application.ActivationPolicyProhibited},
+ Assets: application.AssetOptions{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/seen" {
+ seen <- r.URL.Query().Get("id")
+ return
+ }
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprint(w, ``)
+ })},
+ })
+ h := &host{app: app, tray: &application.SystemTray{}}
+ app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
+ application.InvokeAsync(func() { h.trayCellClick("codex|first@test") })
+ })
+ go func() {
+ if id := <-seen; id != "codex|first@test" {
+ panic("wrong initial target: " + id)
+ }
+ var first *application.WebviewWindow
+ application.InvokeSync(func() {
+ first = h.panel
+ if !first.IsVisible() {
+ panic("initial quota click did not open the panel")
+ }
+ h.trayCellClick("codex|first@test") // the second click closes it
+ })
+ // Native hide dispatches asynchronously, even from the main thread.
+ deadline := time.Now().Add(2 * time.Second)
+ for application.InvokeSyncWithResult(first.IsVisible) {
+ if time.Now().After(deadline) {
+ panic("second quota click did not close the panel")
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ application.InvokeSync(func() {
+ // Same release state as lighten: the closed panel no longer exists.
+ first.Close()
+ h.panel = nil
+ h.tray.AttachWindow(nil)
+ h.trayCellClick("codex|second@test")
+ if h.panel == nil || h.panel == first {
+ panic("panel was not recreated")
+ }
+ })
+ if id := <-seen; id != "codex|second@test" {
+ panic("wrong recreated target: " + id)
+ }
+ fmt.Println("tray quota: recreated and focused twice")
+ app.Quit()
+ }()
+ if err := app.Run(); err != nil {
+ panic(err)
+ }
+ os.Exit(0)
+}
diff --git a/internal/gui/trayusage_tray.go b/internal/gui/trayusage_tray.go
index ce963b39..2fd13279 100644
--- a/internal/gui/trayusage_tray.go
+++ b/internal/gui/trayusage_tray.go
@@ -8,6 +8,8 @@ import (
"strings"
"time"
+ "github.com/wailsapp/wails/v3/pkg/application"
+
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
@@ -25,6 +27,10 @@ func (h *host) watchTrayUsage() {
// a card read while stale is refreshed behind it: read it again once
// that lands, not a tick later, so the menu bar says what the panel does
provider.OnSubscriptionUsage = onTrayUsage
+ // Schedule native cell clicks on the application thread after startup.
+ onTrayCellClick = func(id string) {
+ h.whenReady(func() { application.InvokeAsync(func() { h.trayCellClick(id) }) })
+ }
go func() {
<-h.ready // the tray is made once the app runs
shown, drawn := "", false
@@ -58,12 +64,6 @@ func (h *host) watchTrayUsage() {
// the Mac's cells as an image, the text where it can't be
if len(cells) > 0 {
h.tray.SetLabel("")
- if trayImageShow(cells, trayIcon) {
- drawn = true
- } else {
- h.tray.SetLabel(label)
- shown = "" // and try again next time
- }
} else {
if drawn {
drawn = false
@@ -74,6 +74,16 @@ func (h *host) watchTrayUsage() {
}
h.tray.SetTooltip(tip)
}
+ // Reapply even unchanged cells: a system menu-bar rebuild can
+ // replace the composed image with Wails' bird icon.
+ if len(cells) > 0 {
+ if trayImageShow(cells, trayIcon) {
+ drawn = true
+ } else {
+ h.tray.SetLabel(label)
+ shown = "" // and try again next time
+ }
+ }
// the first answer can take a while; look again soon after it
next := trayUsageEvery()
if label == "" && len(s.TrayUsages) > 0 {
@@ -86,3 +96,20 @@ func (h *host) watchTrayUsage() {
}
}()
}
+
+// trayCellClick opens the allowance represented by the clicked image cell.
+// A visible panel closes on the next click, as clicking the bird does.
+func (h *host) trayCellClick(id string) {
+ if id == "" {
+ return
+ }
+ if settings.Load().Tray == "window" {
+ h.ShowMain(quotaView(id))
+ return
+ }
+ w, _ := h.panelWin()
+ if !w.IsVisible() {
+ w.ExecJS(panelQuotaJS(id))
+ }
+ h.togglePanel()
+}