gui: the tray panel's Allowances tab hides subscriptions and moves them, from an Arrange at its foot (H20 on Discord: "菜单栏的详情页能不能选择某些不显示,有点额度并不想看到或者可以调整顺序,目前的还不行") — the panel's tab already followed the Usage page's order (usageOrder, byUsageOrder), but it could be set only by dragging a card's logo on the window's Usage page, which nothing in the panel said, and there was no way to leave a subscription out of the panel at all. The tab's foot now has Arrange, and "{n} hidden" beside it when some are: Arrange lists a row a subscription (balances too) in the shared order, its logo the handle (a drag, or Alt+arrows; the grip by it on hover only, as on the Usage page) and a Hide/Show pill, under a note that hiding is the panel's only and the order is the Usage page's too; Done draws the cards again. What's hidden is kept in magpie's settings (settings.PanelUsageHidden, panelUsageHidden), not the browser: routing, caps, the Usage page and the menu bar's cells still have it, and a menu bar cell for a hidden subscription still opens its card (panelPeek) until the panel is put away or the tab left. POST /api/usage/arrange takes order and panelHidden and changes only the one it is sent (both were whole-order before; an order alone still works), and the Settings save keeps panelUsageHidden as it does usageOrder. Saves draw at once and are put back with the error if they fail; no click moves the page (the view's scroll is kept across the redraw); no native select, no left-border stripe. Which allowances the menu bar shows beside its icon stays Settings' pick (trayUsages). New t() strings have zh, ja and de. TestUsageArrangePanelHidden (each field set and cleared on its own, a Settings save between) passes -race -count=20, TestSettingsSaveKeepsWhatItDoesNotSend's fixture names the field. panel-arrange.test.cjs (440px: the hidden card left out and counted, Arrange keeping the reader's scroll, the rows and note, the hover-only grip, no stripe, no sideways overflow, Show/Hide posting panelHidden without a scroll and a failed save put back, Alt+arrow and a drag posting the order, Done's cards in it, a menu bar cell opening a hidden card until the panel hides, the Usage page keeping the hidden card) passes on Chromium and WebKit in en, zh, ja and de; click-scroll, gui-ja, gui-de, i18n-keys, panel-fold, panel-usage, quota-*, routing-quota-left, tray-cell-click, tray-inuse, tray-logos, tray-usages, usage-accounts(-fold), usage-arrange, usage-fold, usage-quota-head, usage-card-refresh, usage-stale-answer, usage-remote-cards, api-key-usage, claude-usage-asked and usage-checkin pass on both. go test -tags nogui ./internal/... under a temp HOME, go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried in the real tray panel on a Mac. docs/reference.md and docs/subsystems/gui-shell.md say it.
This commit is contained in:
1 parent
588e51ea4c
commit
490d2ea362
11 files changed
+426
-5
No files matched your search
@@ -882,6 +882,16 @@ It changes the gateway's routing only, never the account Codex is signed in
|
||||
to. The credits an account holds show beside its windows in `magpie quota`,
|
||||
`magpie accounts`, the Usage page and the menu bar panel.
|
||||
|
||||
The Usage page's cards and the menu bar panel's *Allowances* tab are in one
|
||||
order: drag a card's logo on the Usage page (or Alt+arrow keys on it), or
|
||||
press *Arrange* at the foot of the panel's tab and move the rows there. The
|
||||
same *Arrange* hides a subscription from the panel's tab (*Hide*, *Show*
|
||||
puts it back), and the tab's foot says how many are hidden. Hiding is the
|
||||
panel's only: routing, caps, the Usage page and the menu bar's own cells
|
||||
still have it, and a menu bar cell for it still opens its card. Both are
|
||||
kept in magpie's settings (`usageOrder`, `panelUsageHidden`). Which
|
||||
allowances the menu bar itself shows beside its icon is picked in Settings.
|
||||
|
||||
The *Gateway* tab in the app has this as copy buttons and ready-made
|
||||
snippets (shell, curl, Python, Node) for each API, the list of model ids,
|
||||
and the recent calls; `MAGPIE_DEBUG=1` logs every call to the terminal.
|
||||
|
||||
@@ -32,6 +32,7 @@ three.
|
||||
- Every user-visible string has zh, ja and de translations with the same placeholders. `gui-ja.test.cjs` and `gui-de.test.cjs` fail on a missing one.
|
||||
- A click never moves the page. Code scrolls a view only with the reader's event in hand (`scrollOnPurpose`); `click-scroll.test.cjs` guards this.
|
||||
- There are no native `<select>` elements and no colored left-border stripes. State is shown with a dot or a swatch.
|
||||
- The Usage page's cards and the tray panel's Allowances tab share one order, `settings.UsageOrder` (`byUsageOrder`). The panel's *Arrange* (`panelArrange`) moves rows in it and hides subscriptions from that tab alone (`settings.PanelUsageHidden`); both save through `POST /api/usage/arrange`, which changes only the field it is sent, and the Settings save keeps both. Hiding is display only: a menu bar cell's card shows though hidden (`panelPeek`). See `TestUsageArrangePanelHidden` and `panel-arrange.test.cjs`.
|
||||
- The page must work in Chromium (Windows' WebView2) and WebKit (macOS, and WebKitGTK on Linux). GUI tests run in both engines.
|
||||
- Tests never touch a live agent config. The package's `TestMain` runs under `testenv`'s home of its own. Playwright tests serve `assets/` with isolated `/api` fixtures.
|
||||
- `POST /api/settings/codex-auto-review` accepts an empty value (Codex's own choice) or an exact model/group ID in `provider.Served`, including unlisted providers. A known provider with an unknown or empty model name is rejected without changing the saved reviewer or catalog tag. Generic gateway request resolution remains permissive; it is not the validator for this setting. See `Handler` in [`api.go`](../../internal/gui/api.go) and `TestCodexAutoReviewSetting` in [`codex_auto_review_test.go`](../../internal/gui/codex_auto_review_test.go).
|
||||
|
||||
+15
-3
@@ -981,6 +981,8 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
|
||||
// used or left is the Usage page's toggle as much as Settings', set on its own
|
||||
in.QuotaLeft = cur.QuotaLeft
|
||||
in.UsageOrder = cur.UsageOrder // the Usage page's, dragged there
|
||||
// and what the tray panel's Allowances tab leaves out, set there
|
||||
in.PanelUsageHidden = cur.PanelUsageHidden
|
||||
// how agents' lists name models, set on its own for the agents to be told
|
||||
in.PlainNames, in.PlainOwnNames = cur.PlainNames, cur.PlainOwnNames
|
||||
in.CodexAgentsV1 = cur.CodexAgentsV1
|
||||
@@ -1383,15 +1385,25 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
|
||||
}
|
||||
writeJSON(rw, settingsState())
|
||||
})
|
||||
// the Usage page's order of its cards, in magpie's settings
|
||||
// the Usage page's order of its cards, which the tray panel's Allowances
|
||||
// tab follows, and what that tab leaves out, in magpie's settings; one
|
||||
// not sent stays as it is
|
||||
mux.HandleFunc("POST /api/usage/arrange", func(rw http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Order []string }
|
||||
var in struct {
|
||||
Order *[]string
|
||||
PanelHidden *[]string
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
s := settings.Load()
|
||||
s.UsageOrder = in.Order
|
||||
if in.Order != nil {
|
||||
s.UsageOrder = *in.Order
|
||||
}
|
||||
if in.PanelHidden != nil {
|
||||
s.PanelUsageHidden = *in.PanelHidden
|
||||
}
|
||||
if err := settings.Save(s); err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
|
||||
@@ -1302,6 +1302,27 @@ body.panel .profiles #save { height: 25px; } /* the field's: the line keeps its
|
||||
.pq-mode { flex: none; margin-left: auto; height: 15px; padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, var(--fg) 7%, transparent); color: var(--muted); font-size: 9.5px; font-weight: 600; line-height: 15px; letter-spacing: .04em; text-transform: uppercase; transition: background .12s, color .12s; }
|
||||
.pq-more { align-self: flex-start; height: 20px; padding: 0 8px; border-radius: 999px; background: color-mix(in srgb, var(--fg) 7%, transparent); color: var(--muted); font-size: 11px; transition: background .12s, color .12s; }
|
||||
.pq-more:hover { background: color-mix(in srgb, var(--fg) 12%, transparent); color: var(--fg); }
|
||||
/* the tab's foot: Arrange, and how many it hides (H20 on Discord) */
|
||||
.pq-foot { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.pq-hidden { flex: 0 1 auto; min-width: 0; color: var(--faint); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
/* arranging: a row a subscription, its logo the handle (the grip by it on
|
||||
hover), a pill to hide or show it */
|
||||
.pq-anote { margin: 0; color: var(--muted); font-size: 11.5px; line-height: 1.45; overflow-wrap: anywhere; }
|
||||
.pq-alist { display: flex; flex-direction: column; gap: 6px; }
|
||||
.pq-arow { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 7px 10px 7px 14px; border-radius: 10px; background: var(--card); box-shadow: var(--card-edge); }
|
||||
.pq-arow .pq-ahandle { position: relative; width: 22px; height: 22px; margin: -2px; }
|
||||
.pq-arow .pq-ahandle .ic { width: 16px; height: 16px; }
|
||||
.pq-arow .pq-ahandle::before { content: ""; position: absolute; left: -9px; top: 50%; width: 6px; height: 11px; margin-top: -5.5px; background: radial-gradient(circle, var(--muted) .9px, transparent 1.2px) 0 0 / 3px 3.75px; opacity: 0; transition: opacity .15s; }
|
||||
.pq-arow:is(:hover, .dragging) .pq-ahandle::before { opacity: 1; }
|
||||
.pq-arow .pq-ahandle:focus-visible::before { opacity: 1; }
|
||||
.pq-aname { flex: 1 1 auto; min-width: 0; font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pq-arow .pq-hidden { flex: none; }
|
||||
.pq-arow .pq-show { flex: none; align-self: center; }
|
||||
.pq-arow.put-away .pq-ahandle > .ic, .pq-arow.put-away .pq-aname { opacity: .5; }
|
||||
.pq-alist.sorting { user-select: none; -webkit-user-select: none; }
|
||||
.pq-alist.sorting > .pq-arow { transition: transform .22s cubic-bezier(.22, 1, .36, 1); }
|
||||
.pq-alist.sorting .pq-ahandle { cursor: grabbing; }
|
||||
.pq-alist > .pq-arow.dragging { position: relative; z-index: 2; box-shadow: 0 8px 22px rgba(20, 22, 30, .14), 0 0 0 1px var(--line); transition: none; }
|
||||
.pq-mode:hover { background: color-mix(in srgb, var(--fg) 12%, transparent); color: var(--fg); }
|
||||
.pq-gnote + .pq-mode { margin-left: 0; }
|
||||
.pq-mode.flash { animation: pq-mode-flash .9s ease-out; }
|
||||
|
||||
+117
-2
@@ -12608,7 +12608,9 @@ function usageHandle(sub, card) {
|
||||
return b;
|
||||
}
|
||||
|
||||
const usageKeys = () => [...new Set([...$("#subscriptionUsage").children].map((c) => c.dataset.key))];
|
||||
// the subscriptions in the order on the page: the Usage page's cards, or
|
||||
// in the panel the rows of its Allowances tab's arranging
|
||||
const usageKeys = () => [...new Set([...(mode === "panel" ? $$("#panelQuota .pq-arow") : $("#subscriptionUsage").children)].map((c) => c.dataset.key))];
|
||||
|
||||
// moveUsage puts the card at index `to` among those on the page; what the
|
||||
// order named that isn't on it now (an account signed out) keeps its place
|
||||
@@ -12986,6 +12988,14 @@ function planSpan(q) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// panelArranging: the Allowances tab lists its subscriptions to hide and
|
||||
// move (panelArrange); panelPeek: the card a menu bar cell asked for, shown
|
||||
// though the tab hides it, until the panel is put away or the tab left.
|
||||
let panelArranging = false, panelPeek = "";
|
||||
if (mode === "panel") document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden && (panelPeek || panelArranging)) { panelPeek = ""; panelArranging = false; renderPanelQuota(); }
|
||||
});
|
||||
|
||||
// The tray panel is four tabs over the one page: the agents, the allowances
|
||||
// of every subscription and key (the "usage" tab, as it was named before),
|
||||
// what the requests add up to (the "stats" tab, the window's Requests made
|
||||
@@ -13031,6 +13041,7 @@ function setPanelTab(tab) {
|
||||
const b = tabs.querySelector(`[data-ptab="${tab}"]`);
|
||||
if (!b || b.hidden) tab = "agents";
|
||||
closeProfiles();
|
||||
if (tab !== "usage") panelPeek = "";
|
||||
panelTab = tab;
|
||||
try { localStorage.setItem("magpie.panelTab", tab); } catch {}
|
||||
document.body.dataset.ptab = tab;
|
||||
@@ -13119,7 +13130,11 @@ function panelQuotaFocus(id) {
|
||||
if (mode !== "panel") return;
|
||||
quotaFocus = id;
|
||||
quotaFocusUntil = performance.now() + 5000;
|
||||
// a cell for a subscription the tab hides shows its card all the same
|
||||
panelPeek = id;
|
||||
panelArranging = false;
|
||||
setPanelTab("usage");
|
||||
renderPanelQuota();
|
||||
requestAnimationFrame(focusQuotaCard);
|
||||
}
|
||||
|
||||
@@ -13354,9 +13369,12 @@ function renderPanelQuota() {
|
||||
}
|
||||
if (none && panelTab === "usage") setPanelTab("agents");
|
||||
box.hidden = none;
|
||||
// a row held in the arranging: drawn when it's let go
|
||||
if (usageArranging && box.querySelector(".pq-arow")) { usageRenderPending = true; return; }
|
||||
const view = box.closest(".view"), keep = view?.scrollTop;
|
||||
const restoreFlash = keepQuotaFlash(box);
|
||||
box.replaceChildren();
|
||||
if (none) { quotaFocus = ""; fit(); return; }
|
||||
if (none) { quotaFocus = ""; panelArranging = false; fit(); return; }
|
||||
if (!quotas) {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const card = el("div", "pq-card");
|
||||
@@ -13366,9 +13384,21 @@ function renderPanelQuota() {
|
||||
fit();
|
||||
return;
|
||||
}
|
||||
if (panelArranging) {
|
||||
box.append(panelArrange(subs));
|
||||
if (view && view.scrollTop !== keep) view.scrollTop = keep;
|
||||
fit();
|
||||
return;
|
||||
}
|
||||
// what the arranging hid is left out, but for the one a menu bar cell
|
||||
// was clicked for
|
||||
const hidden = new Set(state.settings?.panelUsageHidden || []);
|
||||
const peek = (q) => !!panelPeek && (trayCardID(q) === panelPeek || q.provider === panelPeek);
|
||||
const away = new Set(subs.filter((q) => hidden.has(q.provider) && !peek(q)).map((q) => q.provider));
|
||||
const groups = new Map();
|
||||
const bals = [];
|
||||
for (const q of subs) {
|
||||
if (away.has(q.provider)) continue;
|
||||
// a balance with windows (Command Code's credits beside its 5-hour and
|
||||
// weekly windows) is the windows' card; a balance alone, a figure
|
||||
if (q.balance && !q.windows?.length) { bals.push(q); continue; }
|
||||
@@ -13445,12 +13475,97 @@ function renderPanelQuota() {
|
||||
g.append(grid);
|
||||
box.append(g);
|
||||
}
|
||||
// the way to hide some and move them, and how many are hidden
|
||||
const foot = el("div", "pq-foot");
|
||||
const arrange = el("button", "pq-more pq-arrange", t("Arrange"));
|
||||
arrange.type = "button";
|
||||
arrange.title = t("Choose which subscriptions this tab shows, and their order");
|
||||
arrange.onclick = () => { panelArranging = true; panelPeek = ""; renderPanelQuota(); $("#panelQuota .pq-arow .pq-ahandle")?.focus({ preventScroll: true }); };
|
||||
foot.append(arrange);
|
||||
if (away.size) foot.append(el("span", "pq-hidden", t("{n} hidden", { n: away.size })));
|
||||
box.append(foot);
|
||||
restoreFlash();
|
||||
if (view && view.scrollTop !== keep && !box.querySelector(".pq-card.flash")) view.scrollTop = keep;
|
||||
panelAge();
|
||||
fit();
|
||||
requestAnimationFrame(focusQuotaCard);
|
||||
}
|
||||
|
||||
// The Allowances tab's arranging (H20 on Discord): a row a subscription,
|
||||
// in the order the Usage page's cards have, its logo the handle to move it
|
||||
// (drag, or Alt+arrows) and a pill to hide it from this tab or show it
|
||||
// again. Hiding is the panel's alone; routing and the rest still use it.
|
||||
function panelArrange(subs) {
|
||||
const g = el("div", "pq-group pq-arranging");
|
||||
const head = el("div", "pq-gh");
|
||||
head.append(el("span", "pq-gn", t("Arrange")));
|
||||
const done = el("button", "pq-mode pq-done", t("Done"));
|
||||
done.type = "button";
|
||||
done.onclick = () => { panelArranging = false; renderPanelQuota(); };
|
||||
head.append(done);
|
||||
g.append(head, el("p", "pq-anote", t("Hiding is for this panel only: routing, the Usage page and the menu bar still use what's hidden. The order is the Usage page's too.")));
|
||||
const hidden = new Set(state.settings?.panelUsageHidden || []);
|
||||
const list = el("div", "pq-alist");
|
||||
const seen = new Set();
|
||||
for (const q of subs) {
|
||||
if (seen.has(q.provider)) continue;
|
||||
seen.add(q.provider);
|
||||
const off = hidden.has(q.provider);
|
||||
const row = el("div", "pq-arow" + (off ? " put-away" : ""));
|
||||
row.dataset.key = q.provider;
|
||||
const h = el("button", "ag-handle pq-ahandle");
|
||||
h.type = "button";
|
||||
h.setAttribute("aria-label", t("Arrange {agent}", { agent: q.name }));
|
||||
h.title = t("Drag to reorder · Alt+arrow keys to move");
|
||||
h.append(icon(q.icon || "generic"));
|
||||
h.onkeydown = (e) => {
|
||||
const step = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key];
|
||||
if (!e.altKey || !step) return;
|
||||
e.preventDefault();
|
||||
moveUsage(q.provider, usageKeys().indexOf(q.provider) + step);
|
||||
$(`#panelQuota .pq-arow[data-key="${CSS.escape(q.provider)}"] .pq-ahandle`)?.focus({ preventScroll: true });
|
||||
};
|
||||
h.onpointerdown = (e) => {
|
||||
if (usageArranging) return;
|
||||
usageArranging = dragCards(e, h, row, list, [...list.children], (to) => moveUsage(q.provider, to), () => {
|
||||
usageArranging = false;
|
||||
if (usageRenderPending) { usageRenderPending = false; renderQuotas(); }
|
||||
});
|
||||
};
|
||||
const pill = el("button", "pq-more pq-show", t(off ? "Show" : "Hide"));
|
||||
pill.type = "button";
|
||||
pill.setAttribute("aria-pressed", String(!off));
|
||||
pill.title = t(off ? "Show {name} in this panel" : "Hide {name} from this panel", { name: q.name });
|
||||
pill.onclick = () => hidePanelUsage(q.provider, !off);
|
||||
row.append(h, el("span", "pq-aname", q.name), off ? el("span", "pq-hidden", t("Hidden")) : "", pill);
|
||||
list.append(row);
|
||||
}
|
||||
g.append(list);
|
||||
return g;
|
||||
}
|
||||
|
||||
// hidePanelUsage hides a subscription from the panel's Allowances tab, or
|
||||
// shows it again: drawn at once, put back if the save fails.
|
||||
async function hidePanelUsage(key, hide) {
|
||||
const prev = state.settings;
|
||||
const cur = (prev.panelUsageHidden || []).filter((k) => k !== key);
|
||||
const panelHidden = hide ? [...cur, key] : cur;
|
||||
state.settings = { ...prev, panelUsageHidden: panelHidden };
|
||||
const again = () => {
|
||||
renderPanelQuota();
|
||||
$(`#panelQuota .pq-arow[data-key="${CSS.escape(key)}"] .pq-show`)?.focus({ preventScroll: true });
|
||||
};
|
||||
again();
|
||||
try {
|
||||
const s = await api("usage/arrange", { panelHidden });
|
||||
state.settings = { ...state.settings, panelUsageHidden: s.panelUsageHidden || [] };
|
||||
} catch (e) {
|
||||
state.settings = prev;
|
||||
again();
|
||||
status(e.message, "err");
|
||||
}
|
||||
}
|
||||
|
||||
// asOfText: an allowance standing in for one that couldn't be read just
|
||||
// now (a vendor rate limiting its usage endpoint) says when it was read.
|
||||
function asOfText(q, short = false) {
|
||||
|
||||
@@ -194,6 +194,11 @@ const I18N = {
|
||||
"{n} hidden agents": "已隐藏的 Agent({n} 个)",
|
||||
"Show {n} more ({h} hidden)": "显示其余 {n} 个(含 {h} 个已隐藏)",
|
||||
"Hidden": "已隐藏",
|
||||
"Arrange": "整理",
|
||||
"Choose which subscriptions this tab shows, and their order": "选择这一页显示哪些订阅,以及它们的顺序",
|
||||
"Hiding is for this panel only: routing, the Usage page and the menu bar still use what's hidden. The order is the Usage page's too.": "隐藏只影响这个面板:路由、用量页和菜单栏照常使用被隐藏的订阅。顺序与用量页共用。",
|
||||
"Show {name} in this panel": "在这个面板里显示 {name}",
|
||||
"Hide {name} from this panel": "在这个面板里隐藏 {name}",
|
||||
"Not set up": "未设置",
|
||||
"Hidden by you · show it in the list again": "你隐藏了它 · 点击放回列表",
|
||||
"{name} has magpie as a provider · click to add it again": "{name} 已添加 magpie 供应商 · 点击可再次添加",
|
||||
@@ -3687,6 +3692,11 @@ const I18N = {
|
||||
"{n} hidden agents": "非表示のエージェント({n})",
|
||||
"Show {n} more ({h} hidden)": "さらに {n} 件表示(うち {h} 件は非表示)",
|
||||
"Hidden": "非表示",
|
||||
"Arrange": "整理",
|
||||
"Choose which subscriptions this tab shows, and their order": "このタブに表示するサブスクリプションと、その順番を選びます",
|
||||
"Hiding is for this panel only: routing, the Usage page and the menu bar still use what's hidden. The order is the Usage page's too.": "非表示はこのパネルだけのものです。ルーティング、使用量ページ、メニューバーでは非表示のものも引き続き使われます。順番は使用量ページと共通です。",
|
||||
"Show {name} in this panel": "このパネルに {name} を表示",
|
||||
"Hide {name} from this panel": "このパネルで {name} を非表示",
|
||||
"Not set up": "未設定",
|
||||
"Hidden by you · show it in the list again": "非表示にしました · 一覧に戻す",
|
||||
"{name} has magpie as a provider · click to add it again": "{name} には magpie がプロバイダとして追加済み · クリックで再追加",
|
||||
@@ -7179,6 +7189,11 @@ const I18N = {
|
||||
"{n} hidden agents": "{n} ausgeblendete Agenten",
|
||||
"Show {n} more ({h} hidden)": "{n} weitere anzeigen ({h} ausgeblendet)",
|
||||
"Hidden": "Ausgeblendet",
|
||||
"Arrange": "Anordnen",
|
||||
"Choose which subscriptions this tab shows, and their order": "Wählen, welche Abos dieser Tab zeigt und in welcher Reihenfolge",
|
||||
"Hiding is for this panel only: routing, the Usage page and the menu bar still use what's hidden. The order is the Usage page's too.": "Ausblenden gilt nur für dieses Panel: Routing, die Nutzungsseite und die Menüleiste nutzen Ausgeblendetes weiter. Die Reihenfolge teilt es mit der Nutzungsseite.",
|
||||
"Show {name} in this panel": "{name} in diesem Panel zeigen",
|
||||
"Hide {name} from this panel": "{name} in diesem Panel ausblenden",
|
||||
"Not set up": "Nicht eingerichtet",
|
||||
"Hidden by you · show it in the list again": "Von Ihnen ausgeblendet · wieder in der Liste anzeigen",
|
||||
"{name} has magpie as a provider · click to add it again": "{name} hat magpie als Anbieter · klicken, um es erneut hinzuzufügen",
|
||||
|
||||
@@ -136,6 +136,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) {
|
||||
RequestArchiveMaxMB: 64,
|
||||
QuotaLeft: true,
|
||||
UsageOrder: []string{"kimi", "codex"},
|
||||
PanelUsageHidden: []string{"deepseek"},
|
||||
PlainNames: true,
|
||||
PlainOwnNames: true,
|
||||
CodexAgentsV1: true,
|
||||
|
||||
@@ -587,6 +587,18 @@ 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`.
|
||||
|
||||
`panel-arrange.test.cjs` opens the tray panel's Allowances tab at 440px with a
|
||||
subscription hidden in the settings: it has no card and the foot says one is
|
||||
hidden. *Arrange* keeps the page where the reader scrolled it and lists a row
|
||||
a subscription in the shared order, with the note that hiding is the panel's
|
||||
only; the grip by a logo shows on hover only, and no row has a left stripe or
|
||||
runs off sideways. *Show* and *Hide* post `panelHidden` without moving the
|
||||
page, a failed save is put back, Alt+arrow and a drag by the logo post the
|
||||
`order`, and *Done* draws the cards in it. A menu bar cell for a hidden
|
||||
subscription still opens its card until the panel is put away, and the Usage
|
||||
page keeps the hidden card in the same order. It runs in English, Chinese,
|
||||
Japanese and German on Chromium and WebKit, with a mocked API.
|
||||
|
||||
`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,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// The tray panel's Allowances tab hides subscriptions and moves them (H20 on
|
||||
// Discord): Arrange lists them, a pill hides or shows one, the logo moves it
|
||||
// (Alt+arrows, drag) in the order the Usage page shares; what's hidden stays
|
||||
// on the Usage page, and a menu bar cell still opens its card. 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 = [
|
||||
{ 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 }] },
|
||||
{ provider: "codex", name: "Codex", icon: "codex-color", user: "other@例子.test", windows: [{ name: "Weekly", used: 25 }] },
|
||||
{ provider: "deepseek", name: "DeepSeek", icon: "deepseek-color", windows: [], balance: "¥12.30" },
|
||||
{ provider: "kimi", name: "Kimi Code 的一个名字很长很长的订阅 with a long name", icon: "kimi-color", windows: [{ name: "Weekly", used: 30 }] },
|
||||
];
|
||||
|
||||
function serve(lang, posts, fail) {
|
||||
const settings = { theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd", usageOrder: [], panelUsageHidden: ["kimi"] };
|
||||
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({ agents: [], profiles: [], settings });
|
||||
if (url.pathname === "/api/settings") return json(settings);
|
||||
if (url.pathname === "/api/usage/arrange") {
|
||||
const body = route.request().postDataJSON();
|
||||
posts.push(body);
|
||||
if (fail.next) { fail.next = false; return route.fulfill({ status: 500, body: "disk full" }); }
|
||||
if (body.order) settings.usageOrder = body.order;
|
||||
if (body.panelHidden) settings.panelUsageHidden = body.panelHidden;
|
||||
return json(settings);
|
||||
}
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
|
||||
if (url.pathname === "/api/usage/quotas") return json(quotas);
|
||||
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", "ja", "de"]) {
|
||||
test(`${engine} ${lang}: the panel's Allowances tab hides and moves subscriptions`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(async () => { await browser.close(); });
|
||||
const errors = [], posts = [], fail = { next: false };
|
||||
const context = await browser.newContext({ viewport: { width: 440, height: 300 }, reducedMotion: "reduce" });
|
||||
await context.addInitScript(() => {
|
||||
try { localStorage.setItem("magpie.panelTab", "usage"); } catch {}
|
||||
window.__scrolled = 0;
|
||||
const scroll = Element.prototype.scrollIntoView;
|
||||
Element.prototype.scrollIntoView = function (o) { window.__scrolled++; scroll.call(this, o); };
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", serve(lang, posts, fail));
|
||||
await page.goto("http://magpie.test/?mode=panel");
|
||||
await page.waitForFunction(() => document.querySelectorAll("#panelQuota .pq-card").length >= 3);
|
||||
const tr = (s, v) => page.evaluate(([s, v]) => t(s, v), [s, v]);
|
||||
const cards = () => page.evaluate(() => [...document.querySelectorAll("#panelQuota .pq-card")].map((c) => c.dataset.card));
|
||||
const rows = () => page.evaluate(() => [...document.querySelectorAll("#panelQuota .pq-arow")].map((r) => r.dataset.key));
|
||||
const view = page.locator("#view-agents");
|
||||
const noSideways = async () => assert(await page.evaluate(() => {
|
||||
const v = document.querySelector("#view-agents");
|
||||
return v.scrollWidth <= v.clientWidth && document.scrollingElement.scrollWidth <= innerWidth
|
||||
&& [...document.querySelectorAll("#panelQuota .pq-arow, #panelQuota .pq-foot")].every((r) => r.scrollWidth <= r.clientWidth + 1 && r.getBoundingClientRect().right <= innerWidth);
|
||||
}), "nothing runs off sideways at 440px");
|
||||
|
||||
// what the settings hide is left out, and the foot says how many
|
||||
assert(!(await cards()).includes("kimi"), "the hidden subscription has no card");
|
||||
assert.equal(await page.locator("#panelQuota .pq-foot .pq-hidden").textContent(), await tr("{n} hidden", { n: 1 }));
|
||||
assert.equal(await page.locator("#panelQuota .pq-arrange").textContent(), await tr("Arrange"));
|
||||
await noSideways();
|
||||
|
||||
// Arrange: a click that doesn't move the page; a row each, in order
|
||||
// the reader scrolls to the foot, as a reader does: by the wheel
|
||||
await page.mouse.move(220, 150);
|
||||
await page.mouse.wheel(0, 1000);
|
||||
await page.waitForFunction(() => { const v = document.querySelector("#view-agents"); return v.scrollTop > 0 && v.scrollTop >= v.scrollHeight - v.clientHeight - 1; });
|
||||
const keep = await view.evaluate((v) => v.scrollTop);
|
||||
await page.locator("#panelQuota .pq-arrange").click();
|
||||
await page.waitForSelector("#panelQuota .pq-arow");
|
||||
const after = await view.evaluate((v) => [v.scrollTop, v.scrollHeight - v.clientHeight]);
|
||||
assert.equal(after[0], Math.min(keep, after[1]), "Arrange keeps the reader where they were");
|
||||
assert.deepEqual(await rows(), ["claude", "codex", "deepseek", "kimi"]);
|
||||
assert.equal(await page.locator("#panelQuota .pq-anote").textContent(), await tr("Hiding is for this panel only: routing, the Usage page and the menu bar still use what's hidden. The order is the Usage page's too."));
|
||||
assert(await page.locator('#panelQuota .pq-arow[data-key="kimi"]').evaluate((r) => r.classList.contains("put-away")));
|
||||
assert.equal(await page.locator('#panelQuota .pq-arow[data-key="kimi"] .pq-show').textContent(), await tr("Show"));
|
||||
assert.equal(await page.locator('#panelQuota .pq-arow[data-key="claude"] .pq-show').textContent(), await tr("Hide"));
|
||||
for (const w of await page.evaluate(() => [...document.querySelectorAll("#panelQuota .pq-arow")].map((r) => getComputedStyle(r).borderLeftWidth))) assert.equal(w, "0px", "no stripe down a row's left");
|
||||
await noSideways();
|
||||
|
||||
// the grip by the logo is there on hover only
|
||||
await page.mouse.move(5, 5);
|
||||
const grip = (key) => page.locator(`#panelQuota .pq-arow[data-key="${key}"] .pq-ahandle`).evaluate((h) => getComputedStyle(h, "::before").opacity);
|
||||
await page.waitForFunction(() => getComputedStyle(document.querySelector('#panelQuota .pq-arow[data-key="codex"] .pq-ahandle'), "::before").opacity === "0");
|
||||
await page.locator('#panelQuota .pq-arow[data-key="codex"] .pq-aname').hover();
|
||||
await page.waitForFunction(() => getComputedStyle(document.querySelector('#panelQuota .pq-arow[data-key="codex"] .pq-ahandle'), "::before").opacity === "1");
|
||||
assert.equal(await grip("claude"), "0");
|
||||
|
||||
// hide and show: the pill posts what's hidden, and doesn't scroll
|
||||
const top = await view.evaluate((v) => v.scrollTop);
|
||||
await page.locator('#panelQuota .pq-arow[data-key="kimi"] .pq-show').click();
|
||||
await page.waitForFunction(() => !document.querySelector('#panelQuota .pq-arow[data-key="kimi"]').classList.contains("put-away"));
|
||||
await page.locator('#panelQuota .pq-arow[data-key="deepseek"] .pq-show').click();
|
||||
await page.waitForFunction(() => document.querySelector('#panelQuota .pq-arow[data-key="deepseek"]').classList.contains("put-away"));
|
||||
assert.deepEqual(posts.splice(0), [{ panelHidden: [] }, { panelHidden: ["deepseek"] }]);
|
||||
assert.equal(await view.evaluate((v) => v.scrollTop), top, "a pill's click doesn't move the page");
|
||||
assert(await page.evaluate(() => document.activeElement?.closest(".pq-arow")?.dataset.key === "deepseek"), "the keyboard stays on the pill");
|
||||
|
||||
// a save that fails is put back
|
||||
fail.next = true;
|
||||
await page.locator('#panelQuota .pq-arow[data-key="claude"] .pq-show').click();
|
||||
await page.waitForFunction(() => document.querySelector("#status")?.textContent.includes("disk full") || document.body.textContent.includes("disk full"));
|
||||
assert(!(await page.locator('#panelQuota .pq-arow[data-key="claude"]').evaluate((r) => r.classList.contains("put-away"))), "the failed hide is undone");
|
||||
posts.splice(0);
|
||||
|
||||
// Alt+arrow moves a row, the keyboard staying on its logo
|
||||
await page.locator('#panelQuota .pq-arow[data-key="claude"] .pq-ahandle').focus();
|
||||
await page.keyboard.press("Alt+ArrowDown");
|
||||
await page.waitForFunction(() => document.querySelector("#panelQuota .pq-arow")?.dataset.key === "codex");
|
||||
assert.deepEqual(await rows(), ["codex", "claude", "deepseek", "kimi"]);
|
||||
assert.deepEqual(posts.splice(0), [{ order: ["codex", "claude", "deepseek", "kimi"] }]);
|
||||
assert(await page.evaluate(() => document.activeElement?.matches('.pq-arow[data-key="claude"] .pq-ahandle')));
|
||||
|
||||
// a drag by the logo: DeepSeek to the top
|
||||
const from = await page.locator('#panelQuota .pq-arow[data-key="deepseek"] .pq-ahandle').boundingBox();
|
||||
const to = await page.locator('#panelQuota .pq-arow[data-key="codex"]').boundingBox();
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 8; i++) await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 + (to.y - from.y - 4) * i / 8);
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction(() => document.querySelector("#panelQuota .pq-arow")?.dataset.key === "deepseek");
|
||||
assert.deepEqual(posts.splice(0), [{ order: ["deepseek", "codex", "claude", "kimi"] }]);
|
||||
|
||||
// Done: the cards in the new order, DeepSeek left out
|
||||
await page.locator("#panelQuota .pq-done").click();
|
||||
await page.waitForFunction(() => !document.querySelector("#panelQuota .pq-arow"));
|
||||
assert.deepEqual(await cards(), ["codex|x@y.z", "claude|a@b.c", "kimi"]);
|
||||
assert.equal(await page.locator("#panelQuota .pq-foot .pq-hidden").textContent(), await tr("{n} hidden", { n: 1 }));
|
||||
await noSideways();
|
||||
assert.equal(await page.evaluate(() => window.__scrolled), 0, "nothing scrolled itself");
|
||||
|
||||
// a menu bar cell for the hidden one opens its card all the same
|
||||
await page.evaluate(() => panelQuotaFocus("deepseek"));
|
||||
await page.waitForFunction(() => document.querySelector('#panelQuota .pq-card[data-card="deepseek"]')?.classList.contains("flash"));
|
||||
assert.equal(await page.locator("#panelQuota .pq-foot .pq-hidden").count(), 0, "the one asked for isn't counted as hidden");
|
||||
// put away, the panel hides it again
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, "hidden", { configurable: true, get: () => true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await page.waitForFunction(() => !document.querySelector('#panelQuota .pq-card[data-card="deepseek"]'));
|
||||
|
||||
// the Usage page keeps every card, in the shared order
|
||||
const win = await context.newPage();
|
||||
win.on("pageerror", (e) => errors.push(e.message));
|
||||
await win.route("**/*", serve(lang, [], {}));
|
||||
await win.goto("http://magpie.test/?view=usage&tab=usage");
|
||||
await win.evaluate((s) => { state.settings = { ...state.settings, ...s }; renderQuotas(); }, { usageOrder: ["deepseek", "codex", "claude", "kimi"], panelUsageHidden: ["deepseek"] });
|
||||
await win.waitForFunction(() => document.querySelectorAll("#subscriptionUsage > [data-key]").length >= 4);
|
||||
assert.deepEqual(await win.evaluate(() => [...new Set([...document.querySelectorAll("#subscriptionUsage > [data-key]")].map((c) => c.dataset.key))]), ["deepseek", "codex", "claude", "kimi"]);
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
|
||||
// The tray panel's Allowances tab leaves out the subscriptions set hidden
|
||||
// there (H20 on Discord), kept beside the Usage page's order: each is saved
|
||||
// on its own, the other left as it was, and both outlast a save of the
|
||||
// Settings page.
|
||||
func TestUsageArrangePanelHidden(t *testing.T) {
|
||||
h := t.TempDir()
|
||||
t.Setenv("HOME", h)
|
||||
t.Setenv("USERPROFILE", h)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
|
||||
t.Setenv("APPDATA", filepath.Join(h, "AppData", "Roaming"))
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(h, "AppData", "Local"))
|
||||
post := func(path, body string) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
Handler(nil, nil).ServeHTTP(rec, httptest.NewRequest("POST", path, strings.NewReader(body)))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: %d %s", path, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
want := func(order, hidden []string) {
|
||||
t.Helper()
|
||||
s := settings.Load()
|
||||
if !slices.Equal(s.UsageOrder, order) || !slices.Equal(s.PanelUsageHidden, hidden) {
|
||||
t.Fatalf("order %v hidden %v, want %v %v", s.UsageOrder, s.PanelUsageHidden, order, hidden)
|
||||
}
|
||||
}
|
||||
post("/api/usage/arrange", `{"order":["kimi","codex"]}`)
|
||||
want([]string{"kimi", "codex"}, nil)
|
||||
post("/api/usage/arrange", `{"panelHidden":["deepseek"," deepseek","claude"]}`)
|
||||
want([]string{"kimi", "codex"}, []string{"deepseek", "claude"})
|
||||
post("/api/usage/arrange", `{"order":["codex","kimi"]}`)
|
||||
want([]string{"codex", "kimi"}, []string{"deepseek", "claude"})
|
||||
post("/api/settings", `{"theme":"dark"}`)
|
||||
want([]string{"codex", "kimi"}, []string{"deepseek", "claude"})
|
||||
post("/api/usage/arrange", `{"panelHidden":[]}`)
|
||||
want([]string{"codex", "kimi"}, nil)
|
||||
}
|
||||
@@ -318,6 +318,11 @@ type Settings struct {
|
||||
// own order. Only the page's: the order providers are tried in is the
|
||||
// Providers page's.
|
||||
UsageOrder []string `json:"usageOrder,omitempty"`
|
||||
// PanelUsageHidden are the subscriptions, by provider id, that the tray
|
||||
// panel's Allowances tab leaves out (H20 on Discord). It is what the
|
||||
// panel shows, nothing more: routing, caps, the Usage page and the menu
|
||||
// bar's cells still have them. The panel's order is UsageOrder.
|
||||
PanelUsageHidden []string `json:"panelUsageHidden,omitempty"`
|
||||
// Visible narrows the models an agent is shown, by agent id: the
|
||||
// families (the tag a provider or group is given), provider ids and
|
||||
// group ids its lists hold. An agent it doesn't name is shown them all.
|
||||
@@ -919,6 +924,7 @@ func Save(s Settings) error {
|
||||
s.RedactRules = rules
|
||||
s.AgentOrder, s.AgentsHidden, s.AgentsShown = ids(s.AgentOrder), ids(s.AgentsHidden), ids(s.AgentsShown)
|
||||
s.UsageOrder = ids(s.UsageOrder)
|
||||
s.PanelUsageHidden = ids(s.PanelUsageHidden)
|
||||
s.TrayUsages = ids(s.TrayUsages)
|
||||
for i, u := range s.CodexAutoReset {
|
||||
s.CodexAutoReset[i] = strings.ToLower(u)
|
||||
|
||||
Reference in new issue
Block a user