gui: an agent row shows a faint grip in its margin, and a hidden agent's Show is always there (#479: hiding an agent was found only by right-clicking) — the row's handle is its logo, which turned into a grip and opened Move up, Move down, Hide on a click, but only showed it was a handle with the pointer on it, and a hidden agent'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 now carries a faint grip (two columns of three dots, --faint, --muted with the pointer on the row) in its left margin, beside the logo: it is the handle's own ::before, so it takes no room from the row, a drag on it moves the row and a click on it opens the menu. The Show button on a row hidden by hand is drawn without the pointer on it. Rows in the fold carry no grip, since they aren't dragged. agent-grip.test.cjs (window and tray panel, Chromium, WebKit, en, zh) drags a row by its grip, hides one from the grip's menu and shows it again from the fold, and fails without either half of the change. account-arrange, agent-layout, panel-fold, rollup-short, click-scroll, old-webkit, seg-contrast, header-centre, header-zoom-fit pass. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass but for TestClaudeSignedOut, which fails on its own as well and doesn't build on internal/gui (TestUserPath and TestSubscriptionUsageEachAccount flaked under load, pass alone).
This commit is contained in:
3 files changed
+152
-6
No files matched your search
@@ -324,6 +324,14 @@ body.window .ag-handle { width: 28px; height: 28px; }
|
||||
#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 */
|
||||
#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); }
|
||||
body.panel #agents > .row .ag-handle::before { left: -9px; }
|
||||
#agents > .row:is(:hover, .dragging) .ag-handle::before { --grip-dot: var(--muted); }
|
||||
/* 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; }
|
||||
@@ -331,12 +339,8 @@ body.window .ag-handle { width: 28px; height: 28px; }
|
||||
.agent-fold .row .ag-show { margin-left: auto; }
|
||||
.ag-show { display: inline-flex; align-items: center; gap: 4px; flex: none; height: 22px; padding: 0 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--card); color: var(--fg-2); font: inherit; font-size: 11.5px; cursor: pointer; transition: background-color .15s, color .15s, border-color .15s; }
|
||||
.ag-show:hover { background: var(--pill); color: var(--fg); }
|
||||
/* it comes up with the row under the pointer, or the keyboard on it; the
|
||||
line over the rows already says why they are there */
|
||||
@media (hover: hover) {
|
||||
.agent-fold .row .ag-show { opacity: 0; transform: translateX(3px); transition: opacity .15s, transform .2s cubic-bezier(.22, 1, .36, 1), background-color .15s, color .15s; }
|
||||
.agent-fold .row:is(:hover, :focus-within) .ag-show { opacity: 1; transform: none; }
|
||||
}
|
||||
/* always there, not only under the pointer: it is the way back for a row
|
||||
hidden by hand, and has to be seen to be found (#479) */
|
||||
.ag-show:focus-visible { outline: 1.5px solid var(--fg-2); outline-offset: 1px; }
|
||||
.ag-show svg { color: var(--muted); }
|
||||
/* an agent whose config was rewritten since magpie set it: a line under
|
||||
|
||||
@@ -1224,3 +1224,16 @@ WebKit, in English and Chinese:
|
||||
```sh
|
||||
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
|
||||
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
|
||||
Chinese:
|
||||
|
||||
```sh
|
||||
node --test internal/gui/tests/agent-grip.test.cjs
|
||||
```
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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
|
||||
// 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.
|
||||
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");
|
||||
|
||||
function fixture(lang) {
|
||||
const state = { agents: ["alpha", "beta", "gamma"].map((id) => ({ id, name: id, icon: "generic", path: "/fixture/" + id, fields: [] })), profiles: [], settings: { lang, theme: "light" } };
|
||||
const posted = [];
|
||||
const route = async (route) => {
|
||||
const request = route.request(), url = new URL(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(state);
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
|
||||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||||
if (url.pathname === "/api/usage/quotas") return json([]);
|
||||
if (url.pathname === "/api/agents/arrange") {
|
||||
const body = request.postDataJSON();
|
||||
posted.push(body);
|
||||
Object.assign(state.settings, { agentOrder: body.order, agentsHidden: body.hidden, agentsShown: body.shown });
|
||||
return json(state.settings);
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
await route.fulfill({ body: await fs.readFile(file), contentType });
|
||||
};
|
||||
return { state, posted, route };
|
||||
}
|
||||
|
||||
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
|
||||
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);
|
||||
return {
|
||||
id: r.dataset.id, content: s.content, image: s.backgroundImage, opacity: Number(s.opacity), visibility: s.visibility, w, ht,
|
||||
inRow: left >= rb.left && left + w <= hb.left + 0.5, x: left + w / 2, y: hb.top + hb.height / 2,
|
||||
};
|
||||
}));
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
test(engine + ": an agent row shows it can be taken hold of, and a hidden one its way back", { timeout: 120000 }, async (t) => {
|
||||
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
const errors = [];
|
||||
t.after(async () => {
|
||||
if (errors.length) console.log(errors);
|
||||
await browser.close();
|
||||
});
|
||||
for (const lang of ["en", "zh"]) for (const mode of ["window", "panel"]) {
|
||||
await t.test(`${lang}, ${mode}`, async () => {
|
||||
const ctx = await browser.newContext({ viewport: mode === "panel" ? { width: 380, height: 560 } : { width: 1000, height: 640 }, reducedMotion: "reduce" });
|
||||
const page = await ctx.newPage(), f = fixture(lang);
|
||||
page.setDefaultTimeout(5000);
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", f.route);
|
||||
await page.goto(`http://magpie.test/?view=agents&mode=${mode}`);
|
||||
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
|
||||
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.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.inRow, `${x.id}'s grip is off its row or over its logo: ${JSON.stringify(x)}`);
|
||||
}
|
||||
|
||||
// dragging by the grip moves the row
|
||||
const before = await order(page);
|
||||
await page.mouse.move(g[0].x, g[0].y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(g[0].x, g[1].y + 14, { steps: 12 });
|
||||
await page.locator("#agents > .row.dragging").waitFor();
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction((id) => document.querySelector("#agents > .row.agent").dataset.id === id, before[1]);
|
||||
assert.deepEqual(f.posted.at(-1).order.slice(0, 3), [before[1], before[0], before[2]]);
|
||||
await page.mouse.move(1, 1);
|
||||
|
||||
// a click on the grip opens the row's menu, which hides it
|
||||
g = await grips(page);
|
||||
const id = g[2].id, y0 = await page.evaluate(() => [scrollY, document.querySelector("#view-agents").scrollTop]);
|
||||
await page.mouse.click(g[2].x, g[2].y);
|
||||
const hide = await page.evaluate(() => t("Hide"));
|
||||
await page.getByRole("menuitem", { name: hide }).click();
|
||||
await page.waitForFunction((id) => !document.querySelector(`#agents > .row.agent[data-id="${id}"]`), id);
|
||||
assert.deepEqual(f.posted.at(-1).hidden, [id]);
|
||||
|
||||
// the fold says something is hidden; opened, its Show is there with
|
||||
// the pointer away, and brings the row back
|
||||
const more = page.locator("#agents > .agent-more");
|
||||
assert.match(await more.textContent(), /1/);
|
||||
await more.click();
|
||||
await page.mouse.move(1, 1);
|
||||
const show = page.locator(`.agent-fold .row.put-away[data-id="${id}"] .ag-show`);
|
||||
await page.waitForFunction((id) => {
|
||||
const b = document.querySelector(`.agent-fold .row.put-away[data-id="${id}"] .ag-show`);
|
||||
return b && getComputedStyle(b).opacity === "1" && b.getBoundingClientRect().width > 0;
|
||||
}, id);
|
||||
assert(await show.isVisible());
|
||||
await show.click();
|
||||
await page.waitForFunction((id) => !!document.querySelector(`#agents > .row.agent[data-id="${id}"]`), id);
|
||||
assert.deepEqual(f.posted.at(-1).hidden, []);
|
||||
assert.deepEqual(await page.evaluate(() => [scrollY, document.querySelector("#view-agents").scrollTop]), y0, "a click moved the page");
|
||||
await ctx.close();
|
||||
});
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user