gui: the header tabs lose their grey track, a Plugins card's move is one word beside the name, and an agent row's grip shows only under the pointer (the user: 这个 tab 栏的背景灰色条有点丑 · 这里的紫色按钮也有点丑,而且还有的变成了两行 · 排序 icon 只在 hover 到这个条目的时候显示,logo hover 的排序 icon 可以删掉)

The header's .seg drew a grey track behind its tabs; now only the open tab's white thumb marks it (the account-mode .am-seg keeps its own). A Plugins card offering a built-in's accounts had a solid purple bar of its own at the card's foot, "Move my 2 Command Code Plan accounts", which broke onto two lines; it is now the tinted pill beside the name that Install is, reading "Move" / "迁移", its title saying whose accounts and how many and what moving does. In the Agents list the grip in a row's left margin (#479) was drawn on every row all the time, and hovering the logo turned it into a second grip; the margin grip now shows only for the row under the pointer, the one being dragged, or a handle with its menu open or the keyboard on it (kept in a rule of its own, for Safari 15), and the logo stays the logo. Dragging or clicking the grip still does what the logo does.

agent-grip now checks no grip is drawn with the pointer away, the hovered row's alone is, and the logo has no grip of its own; plugin-move-overflow checks the move button sits in the card's top row on one line. agent-grip, account-arrange, agent-layout, the agent-models tests, brand, header-centre, header-zoom-fit, phone-web, strip-scrollbar, old-webkit and the plugin tests pass in Chromium and WebKit, except plugin-move's known Chromium flake and click-scroll, which fail the same way on unchanged main on this loaded machine. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build and go test -tags nogui ./internal/gui/... pass.
This commit is contained in:
yetone committed 2026-10-02 10:31:43 +08:00
1 parent ee5f974f98
commit ec10d3bd98
8 files changed
+71 -56

No files matched your search

+13 -16
View File
@@ -201,7 +201,7 @@ body.window:not(.mac):not(.linux):not(.web) .brand { display: none; } /* Windows
.seg {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
display: inline-flex; padding: 2px; border-radius: 8px;
background: var(--seg-track);
/* no grey track behind the tabs: only the open one's thumb */
}
.seg > .thumb { border-radius: 6px; background: var(--seg-thumb); box-shadow: var(--seg-edge); }
body.panel .seg { display: none; }
@@ -329,28 +329,25 @@ body.window .row .ic { width: 22px; height: 22px; }
.agent-fold .row { opacity: 0; transform: translateY(-10px) scale(.985); transform-origin: 50% 0; transition: opacity .22s ease, transform .32s cubic-bezier(.4, 0, .2, 1); transition-delay: calc((var(--n) - 1 - var(--i)) * 22ms); }
.agent-fold.open .row { opacity: 1; transform: none; transition: opacity .42s ease, transform .56s cubic-bezier(.22, 1, .36, 1); transition-delay: calc(70ms + var(--i) * 48ms); }
/* the logo is the row's handle: hovered, it turns into a grip to drag the row
by, and a click opens the row's menu (Move up, Move down, Hide). It takes
no more room than the logo did. */
/* the logo is the row's handle: drag it to move the row, and a click opens
the row's menu (Move up, Move down, Hide). It stays the logo under the
pointer; the grip that says the row can be taken hold of is in its margin. */
.ag-handle { display: inline-grid; place-items: center; flex: none; width: 24px; height: 24px; margin: -3px; padding: 0; border: 0; border-radius: 6px; background: none; color: inherit; cursor: grab; touch-action: none; transition: background-color .15s; }
body.window .ag-handle { width: 28px; height: 28px; }
.ag-handle > .ic, .ag-handle > .grip { grid-area: 1 / 1; transition: opacity .15s, transform .2s cubic-bezier(.22, 1, .36, 1); }
.ag-handle > .grip { display: grid; place-items: center; color: var(--muted); opacity: 0; transform: scale(.8); }
.ag-handle > .ic { grid-area: 1 / 1; transition: opacity .15s; }
.ag-handle:hover, .ag-handle.open { background: var(--pill); }
.ag-handle:focus-visible { background: var(--pill); outline: 1.5px solid var(--fg-2); outline-offset: 0; }
.agent-fold .ag-handle { cursor: default; }
#agents > .row .ag-handle:is(:hover, .open) > .ic, #agents > .row.dragging .ag-handle > .ic { opacity: 0; transform: scale(.8); }
#agents > .row .ag-handle:focus-visible > .ic { opacity: 0; transform: scale(.8); }
#agents > .row .ag-handle:is(:hover, .open) > .grip, #agents > .row.dragging .ag-handle > .grip { opacity: 1; transform: none; }
#agents > .row .ag-handle:focus-visible > .grip { opacity: 1; transform: none; }
/* the logo's grip comes up only on hover, so a row in the list also carries a
faint one in its left margin, always there, that says the row can be taken
hold of (#479): it is the handle's own, so a drag or a click on it does what
the logo does, and it takes no room from the row */
/* a row in the list carries a grip in its left margin (#479), drawn only
while the row is under the pointer, dragged, or its handle has the
keyboard or its menu open: always there, it was noise down every row. It
is the handle's own, so a drag or a click on it does what the logo does,
and it takes no room from the row */
#agents > .row .ag-handle { position: relative; }
#agents > .row .ag-handle::before { content: ""; position: absolute; left: -6px; top: 50%; width: 5px; height: 11px; margin-top: -5.5px; background: radial-gradient(circle, var(--grip-dot) .9px, transparent 1.2px) 0 0 / 3.5px 3.75px; --grip-dot: var(--faint); }
#agents > .row .ag-handle::before { content: ""; position: absolute; left: -6px; top: 50%; width: 5px; height: 11px; margin-top: -5.5px; background: radial-gradient(circle, var(--muted) .9px, transparent 1.2px) 0 0 / 3.5px 3.75px; opacity: 0; transition: opacity .15s; }
body.panel #agents > .row .ag-handle::before { left: -9px; }
#agents > .row:is(:hover, .dragging) .ag-handle::before { --grip-dot: var(--muted); }
#agents > .row:is(:hover, .dragging) .ag-handle::before, #agents > .row .ag-handle.open::before { opacity: 1; }
#agents > .row .ag-handle:focus-visible::before { opacity: 1; }
/* one hidden by hand reads as put away, with its way back beside its name */
.agent-fold .row.put-away .ag-handle > .ic, .agent-fold .row.put-away .name { opacity: .6; }
.agent-fold .row.put-away .who { display: flex; align-items: center; gap: 8px; }
+3 -5
View File
@@ -901,22 +901,20 @@ function setAgentHidden(a, hide) {
}
const ALT = /^Mac/.test(navigator.platform) ? "⌥" : "Alt+";
const GRIP = "M6 4h.01M10 4h.01M6 8h.01M10 8h.01M6 12h.01M10 12h.01";
const EYE_OFF = "M6.6 3.7A6.9 6.9 0 0 1 8 3.5c3.75 0 6.25 4.5 6.25 4.5a11 11 0 0 1-1.5 2M4.4 4.4C2.7 5.55 1.75 8 1.75 8S4.25 12.5 8 12.5c1.2 0 2.25-.45 3.1-1.05M6.75 6.75a1.75 1.75 0 0 0 2.5 2.5M2 2l12 12";
const EYE = "M1.75 8S4.25 3.5 8 3.5 14.25 8 14.25 8 11.75 12.5 8 12.5 1.75 8 1.75 8ZM8 9.75a1.75 1.75 0 1 0 0-3.5 1.75 1.75 0 0 0 0 3.5Z";
// agentHandle is the row's logo, which is also its handle: drag it to move
// the row, click it (or right-click the row) for Move up, Move down and
// Hide; Alt+↑/↓ moves it from the keyboard.
// Hide; Alt+↑/↓ moves it from the keyboard. The grip that says so is drawn
// in the row's margin on hover (app.css), not over the logo.
function agentHandle(a, row, inFold) {
const b = el("button", "ag-handle");
b.type = "button";
b.setAttribute("aria-label", t("Arrange {agent}", { agent: a.name }));
b.setAttribute("aria-haspopup", "menu");
b.title = inFold ? t(isHidden(a) ? "Show {agent}" : "Hide {agent}", { agent: a.name }) : t("Drag to reorder · click to move or hide");
const grip = el("span", "grip");
grip.append(svg(GRIP, 14, 2.4));
b.append(icon(a.icon), grip);
b.append(icon(a.icon));
b.onkeydown = (e) => {
if (inFold || !e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
e.preventDefault();
-1
View File
@@ -88,7 +88,6 @@
.pm-act.done:hover:not(:disabled) { background: var(--green-soft); color: var(--green); }
.pm-act.busy { color: var(--muted); font-weight: 500; }
.pm-act:disabled:not(.busy):not(.done) { opacity: .55; }
.pm-card > .pm-act.move { height: auto; min-height: 26px; padding: 4px 12px; white-space: normal; text-align: center; }
.pm-act .spin { width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid currentColor; border-right-color: transparent; animation: spin .7s linear infinite; }
/* loading: cards drawn as grey bars */
+8 -9
View File
@@ -201,9 +201,13 @@
}
}
const moveButton = (b, c, here) => {
b.classList.add("go", "move");
b.textContent = here ? t("Move {name} here", { name: c.name }) : t(c.accounts === 1 ? "Move my {n} {name} account" : "Move my {n} {name} accounts", { name: c.name, n: c.accounts });
b.title = t("Runs {name} on this plugin in place of the built-in, with the same accounts; nothing changes if one doesn't work through it", { name: c.name });
// one word beside the name, as Install is: the card already says whose
// accounts, the title says how many and what it does (a whole sentence
// was a solid bar under the card that broke onto two lines)
b.classList.add("get", "move");
b.textContent = t("Move");
b.title = (here ? t("Move {name} here", { name: c.name }) : t(c.accounts === 1 ? "Move my {n} {name} account" : "Move my {n} {name} accounts", { name: c.name, n: c.accounts }))
+ " — " + t("Runs {name} on this plugin in place of the built-in, with the same accounts; nothing changes if one doesn't work through it", { name: c.name });
b.disabled = busy.size > 0;
b.onclick = (ev) => { ev.stopPropagation(); move(c); };
return b;
@@ -299,11 +303,7 @@
by.append(v);
} else by.append(el("span", "", l.npm?.publisher || l.package));
who.append(by);
const act = actionFor(l.package, l.name, l);
// a move says whose accounts: too long for the top row, it has one of its own
const own = act.classList.contains("move");
top.append(logo(l.icon), who);
if (!own) top.append(act);
top.append(logo(l.icon), who, actionFor(l.package, l.name, l));
const sum = el("p", "pm-sum", summary(l));
const meta = el("div", "pm-meta");
if (l.npm?.weekly) {
@@ -322,7 +322,6 @@
meta.append(b);
}
c.append(top, sum, meta);
if (own) c.append(act);
c.onclick = () => detail(l);
c.onkeydown = (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); detail(l); } };
return c;
+8 -7
View File
@@ -104,10 +104,10 @@ code the browser page shows; an API-key way opens the account. English and
Chinese, Chromium and WebKit, with the API faked.
`plugin-move-overflow.test.cjs` checks a Plugins card that offers a
built-in's accounts ("Move my 1 Grok (SuperGrok) account"): the button has a
row of its own at the card's foot, so it stays inside the card and the
plugin's name and "magpie community" aren't cut, in a three-, two- and
one-column window. English and Chinese, Chromium and WebKit, with the API
built-in's accounts: the button is one word beside the name, as Install is
("Move", its title saying whose accounts and how many), on one line, and it
stays inside the card and the plugin's name and "magpie community" aren't
cut, in a three-, two- and one-column window. English and Chinese, Chromium and WebKit, with the API
faked.
`plugin-key-hint.test.cjs` checks a plugin's API-key way: its label titles
@@ -1291,9 +1291,10 @@ node --test internal/gui/tests/seg-contrast.test.cjs
```
`agent-grip.test.cjs` checks that hiding an agent can be found without a
right-click (#479): with the pointer away, every row in the Agents list draws
a faint grip in its left margin, beside its logo; dragging the grip moves the
row, and clicking it opens the row's menu, whose Hide puts the row in the fold
right-click (#479): a row in the Agents list under the pointer draws a grip
in its left margin, beside its logo, and no row does with the pointer away;
the logo stays the logo on hover (no second grip over it); dragging the grip
moves the row, and clicking it opens the row's menu, whose Hide puts the row in the fold
at the foot of the list. Opened, the fold's Show button is there without the
pointer on its row, and brings the row back; no click moves the page. It runs
in the window and the tray panel, in Chromium and WebKit, in English and
+27 -12
View File
@@ -1,12 +1,13 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// #479: an agent row's handle (its logo, which turns into a grip on hover;
// a click opens Move up, Move down, Hide) showed nothing until the pointer
// was on it, so hiding an agent was found only by right-clicking, and a
// hidden one's way back, the Show button in the fold at the foot of the
// list, came up only with the pointer on its row. Now every row in the list
// carries a faint grip in its left margin, there with the pointer away: it is
// the handle's own, so dragging it moves the row and clicking it opens the
// menu. Hidden from that menu, the row goes into the fold, whose Show button
// #479: an agent row's handle (its logo; a click opens Move up, Move down,
// Hide) showed nothing until the pointer was on it, so hiding an agent was
// found only by right-clicking, and a hidden one's way back, the Show button
// in the fold at the foot of the list, came up only with the pointer on its
// row. Every row in the list then carried a faint grip in its left margin,
// always there; down every row that was noise, and the logo also turned into
// a second grip on hover. Now the margin grip is drawn only with the pointer
// on its row, and the logo stays the logo: the grip is the handle's own, so
// dragging it moves the row and clicking it opens the menu. Hidden from that menu, the row goes into the fold, whose Show button
// is there without the pointer on it and brings the row back. No click moves
// the page. In the window and the tray panel, Chromium and WebKit, English
// and Chinese; no backend, the API is faked here.
@@ -46,7 +47,7 @@ function fixture(lang) {
}
const order = (page) => page.locator("#agents > .row.agent").evaluateAll((rs) => rs.map((r) => r.dataset.id));
// the faint grip of each row in the list, as drawn with the pointer away
// the margin grip of each row in the list, where it is drawn
const grips = (page) => page.locator("#agents > .row.agent").evaluateAll((rs) => rs.map((r) => {
const h = r.querySelector(".ag-handle"), s = getComputedStyle(h, "::before"), hb = h.getBoundingClientRect(), rb = r.getBoundingClientRect();
const left = hb.left + parseFloat(s.left), w = parseFloat(s.width), ht = parseFloat(s.height);
@@ -76,14 +77,28 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.locator("#agents > .row.agent").nth(2).waitFor();
await page.mouse.move(1, 1);
// with the pointer away, every row's grip is drawn, in its margin
// with the pointer away no row's grip is drawn; it is there, in the
// row's margin, for the row under the pointer, and the logo stays as
// it was
const shown = (page) => page.locator("#agents > .row.agent").evaluateAll((rs) => rs.map((r) => getComputedStyle(r.querySelector(".ag-handle"), "::before").opacity));
await page.waitForFunction(() => [...document.querySelectorAll("#agents > .row.agent .ag-handle")].every((h) => getComputedStyle(h, "::before").opacity === "0"));
let g = await grips(page);
for (const x of g) {
assert.notEqual(x.content, "none", `${x.id} has no grip with the pointer away`);
assert.notEqual(x.content, "none", `${x.id} has no grip`);
assert.match(x.image, /gradient/, `${x.id}'s grip draws nothing`);
assert(x.opacity > 0 && x.visibility === "visible" && x.w >= 4 && x.ht >= 8, `${x.id}'s grip can't be seen: ${JSON.stringify(x)}`);
assert(x.visibility === "visible" && x.w >= 4 && x.ht >= 8, `${x.id}'s grip can't be seen: ${JSON.stringify(x)}`);
assert(x.inRow, `${x.id}'s grip is off its row or over its logo: ${JSON.stringify(x)}`);
}
assert.equal(await page.locator("#agents .ag-handle .grip").count(), 0, "the logo still has a grip of its own");
const row1 = page.locator("#agents > .row.agent").nth(1);
await row1.locator(".name").hover();
await page.waitForFunction(() => {
const rs = [...document.querySelectorAll("#agents > .row.agent .ag-handle")].map((h) => getComputedStyle(h, "::before").opacity);
return rs[1] === "1" && rs.every((o, i) => i === 1 || o === "0");
});
await row1.locator(".ag-handle").hover();
assert.equal(await row1.locator(".ag-handle > .ic").evaluate((i) => getComputedStyle(i).opacity), "1", "the logo changes on hover");
assert.equal((await shown(page))[1], "1");
// dragging by the grip moves the row
const before = await order(page);
@@ -2,8 +2,10 @@
// A Plugins card that offers a built-in's accounts keeps its button inside
// the card: "Move my Grok (SuperGrok) accounts (1)" ran past the card's right
// edge, and Kiro's shorter one squeezed its name to "K…" and cut "magpie
// community". The button now has a row of its own at the card's foot and
// says how many accounts it moves ("Move my 1 Grok (SuperGrok) account").
// community". It then had a solid row of its own at the card's foot, which
// broke onto two lines ("Move my 2 Command Code Plan accounts"); it is now
// one word beside the name, as Install is ("Move"), its title saying whose
// accounts and how many, and stays on one line.
// Every card stays whole, its name and byline uncut, in a three-, two- and
// one-column window. In English and Chinese; the API is faked here.
const assert = require("node:assert/strict");
@@ -46,7 +48,7 @@ function serve(lang) {
};
}
const card = { en: "Move my 1 Grok (SuperGrok) account", zh: "迁移我的 1 个 Grok (SuperGrok) 账号" };
const card = { en: "Move", zh: "迁移" };
const launch = (engine) => engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" });
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
@@ -61,7 +63,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang));
await page.goto("http://magpie.test/?view=plugins");
await page.locator("#view-plugins button", { hasText: card[lang] }).waitFor();
await page.locator("#view-plugins button", { hasText: card[lang] }).first().waitFor();
if (process.env.ARTIFACT_DIR) await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-move-overflow-${engine}-${lang}-${width}.png`) });
const cards = await page.locator("#view-plugins .pm-card").evaluateAll((cs) => cs.map((c) => {
@@ -74,6 +76,10 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
return { name: c.querySelector(".pm-name b")?.textContent, out, cut };
}));
assert.ok(cards.length >= listings.length, "the market's cards aren't there");
// the move is one line, in the card's top row
const moves = await page.locator("#view-plugins .pm-card .pm-act.move").evaluateAll((bs) => bs.map((b) => [b.parentElement.className, Math.round(b.getBoundingClientRect().height)]));
assert.ok(moves.length >= 1);
for (const [row, h] of moves) { assert.equal(row, "pm-top"); assert.equal(h, 26, "one line"); }
const lefts = await page.locator("#view-plugins .pm-card").evaluateAll((cs) => new Set(cs.map((c) => Math.round(c.getBoundingClientRect().left))).size);
assert.equal(lefts, cols, `not ${cols} columns at ${width}px`);
const wrong = cards.flatMap((c) => [...(c.out.length ? [`${c.name}: runs past its card`] : []), ...c.cut.map((x) => `${c.name}: "${x}" is cut`)]);
+2 -2
View File
@@ -75,12 +75,12 @@ const L = {
move: "Move to the plugin", busy: "Installing the plugin and checking each account…", again: "Try again", back: "Use the built-in again",
failed: "It stays built-in: magpie couldn't reach npm to install the plugin. Check the network or proxy, then try again.",
onPlugin: "The community Zed plugin", builtin: "magpie's built-in · or the community Zed plugin", done: "Zed now runs on its plugin — 2 accounts, 1 model.",
subs: "Subscriptions", card: "Move my 2 Zed accounts", own: "Zed itself stays signed in as it is." },
subs: "Subscriptions", card: "Move", own: "Zed itself stays signed in as it is." },
zh: { runs: "运行方式", line: "Zed 可以改由社区插件运行,账号不变。", look: "查看",
move: "迁移到插件", busy: "正在安装插件并逐个检查账号…", again: "重试", back: "改回内置",
failed: "仍使用内置:无法连接 npm 安装插件,请检查网络或代理后重试。",
onPlugin: "社区 Zed 插件", builtin: "magpie 内置 · 也可改用社区 Zed 插件", done: "Zed 现在由插件运行——2 个账号,1 个模型。",
subs: "订阅", card: "迁移我的 2 个 Zed 账号", own: "Zed 本身的登录保持不变。" },
subs: "订阅", card: "迁移", own: "Zed 本身的登录保持不变。" },
};
const launch = (engine) => engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" });