feat(gui): collapse overflowing Sessions agent tabs into a picker (#461)
This commit is contained in:
1 parent
25194cbc0d
commit
876bec1b89
5 files changed
+267
-21
No files matched your search
@@ -6593,6 +6593,7 @@ let protoMenu = null;
|
||||
function closeProtoMenu() {
|
||||
if (!protoMenu) return;
|
||||
protoMenu.anchor.classList.remove("open");
|
||||
if (protoMenu.anchor.hasAttribute("aria-expanded")) protoMenu.anchor.setAttribute("aria-expanded", "false");
|
||||
protoMenu.box.remove();
|
||||
document.removeEventListener("mousedown", protoMenu.outside, true);
|
||||
document.removeEventListener("keydown", protoMenu.keys, true);
|
||||
@@ -6647,6 +6648,7 @@ function openProtoMenu(anchor, opts, value, choose, head = "Protocol this key sp
|
||||
box.style.left = Math.max(pad, Math.min(r.left, innerWidth - w - pad)) + "px";
|
||||
box.style.top = Math.max(pad, y) + "px";
|
||||
anchor.classList.add("open");
|
||||
if (anchor.hasAttribute("aria-expanded")) anchor.setAttribute("aria-expanded", "true");
|
||||
const outside = (e) => { if (!box.contains(e.target) && !anchor.contains(e.target)) closeProtoMenu(); };
|
||||
// Scrolling the menu keeps it open; scrolling outside moves its anchor.
|
||||
const scroll = (e) => { if (!box.contains(e.target)) closeProtoMenu(); };
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
/* Sessions: every session of an agent, by folder, to resume or delete (sessions.js) */
|
||||
.sm-head { gap: 8px; }
|
||||
.sm-head .grow { flex: 1; }
|
||||
.sm-agents { min-width: 0; flex: 0 1 auto; }
|
||||
.sm-head { gap: 8px; height: auto; min-height: 34px; }
|
||||
.sm-switch { min-width: 0; flex: 1; display: flex; align-items: center; }
|
||||
.sm-agents { min-width: 0; max-width: 100%; }
|
||||
.segs.sm-agents { background: var(--seg-track); }
|
||||
.sm-agents .opt { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.sm-agents .opt.on { background: var(--seg-thumb); box-shadow: var(--seg-edge); }
|
||||
.sm-agents .opt.on em { color: var(--muted); }
|
||||
.sm-agents .opt .ic { width: 13px; height: 13px; }
|
||||
.sm-agents .opt em, .sm-trash-btn em { font-style: normal; font-size: 10.5px; color: var(--faint); font-variant-numeric: tabular-nums; }
|
||||
.sm-agent-pick { min-width: 0; max-width: 220px; gap: 6px; color: var(--fg); }
|
||||
.sm-agent-pick .ic { width: 13px; height: 13px; flex: none; }
|
||||
.sm-agent-pick em, .sm-agents em, .sm-trash-btn em { font-style: normal; font-size: 10.5px; color: var(--faint); font-variant-numeric: tabular-nums; }
|
||||
.pop.proto-menu.sm-agent-menu { width: min(280px, calc(100vw - 16px)); max-height: min(360px, 70vh); overflow-y: auto; background: var(--card); }
|
||||
.sm-agent-menu .pm-words { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex: 1; }
|
||||
.sm-agent-menu .pm-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sm-agent-menu .pm-note { flex: none; font-variant-numeric: tabular-nums; }
|
||||
.sm-filter { width: 200px; flex: 0 1 200px; min-width: 110px; }
|
||||
.sm-trash-btn { display: inline-flex; align-items: center; gap: 5px; flex: none; }
|
||||
.sm-trash-btn.on { background: var(--pill); color: var(--fg); }
|
||||
@@ -35,3 +44,10 @@
|
||||
.sm-filter { width: 130px; flex-basis: 130px; }
|
||||
.row.sm-folder .sm-path { display: none; }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.sm-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; row-gap: 6px; }
|
||||
.sm-switch { grid-column: 1 / -1; }
|
||||
.sm-agent-pick { max-width: 100%; }
|
||||
.sm-filter { width: 100%; min-width: 0; }
|
||||
.sm-trash-btn { grid-column: 2; justify-self: end; }
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
let openedFor = ""; // the agent the folders were first unfolded for
|
||||
let detail = ""; // the session opened to its details
|
||||
let loading = 0;
|
||||
let fitObserver = null;
|
||||
|
||||
const TRASH = "M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5M7 7v4M9 7v4";
|
||||
const TERM = "M3 4.5 6 7.5 3 10.5M7.5 11.5h5.5";
|
||||
@@ -49,31 +50,81 @@
|
||||
};
|
||||
|
||||
function draw() {
|
||||
const keepFocus = page.querySelector(".sm-agent-pick:focus, .sm-agents .opt:focus");
|
||||
fitObserver?.disconnect();
|
||||
if (page.querySelector(".sm-agent-pick.open")) closeProtoMenu();
|
||||
page.classList.toggle("loading", !data?.sessions && !failed);
|
||||
page.replaceChildren(head(), body());
|
||||
const wrap = page.querySelector(".sm-switch");
|
||||
const tabs = wrap.querySelector(".sm-agents");
|
||||
const pick = wrap.querySelector(".sm-agent-pick");
|
||||
const fit = () => {
|
||||
const room = parseFloat(getComputedStyle(wrap).width);
|
||||
if (!room) return;
|
||||
const focused = document.activeElement;
|
||||
// Measure the actual labels, including language, font and text zoom,
|
||||
// rather than guessing a breakpoint or a number of agents.
|
||||
tabs.hidden = false;
|
||||
const compact = tabs.scrollWidth > Math.floor(room);
|
||||
tabs.hidden = compact || !data?.agents.length;
|
||||
pick.hidden = !compact || !current();
|
||||
if (!compact && pick.classList.contains("open")) closeProtoMenu();
|
||||
if (compact && tabs.contains(focused)) pick.focus({ preventScroll: true });
|
||||
else if (!compact && focused === pick) tabs.querySelector(".on")?.focus({ preventScroll: true });
|
||||
};
|
||||
fitObserver = new ResizeObserver(fit);
|
||||
fitObserver.observe(wrap);
|
||||
fit();
|
||||
if (keepFocus) (pick.hidden ? tabs.querySelector(".on") : pick)?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function chooseAgent(name) {
|
||||
if (name === data.agent && !trashOn) return;
|
||||
trashOn = false;
|
||||
agent = name;
|
||||
try { localStorage.setItem("magpie.sessionsAgent", name); } catch {}
|
||||
picked.clear();
|
||||
detail = "";
|
||||
data = { ...data, agent: name, sessions: null };
|
||||
draw();
|
||||
load(name);
|
||||
}
|
||||
|
||||
function head() {
|
||||
const h = el("div", "usage-head sm-head");
|
||||
const seg = el("div", "segs regions sm-agents");
|
||||
for (const a of data?.agents || []) {
|
||||
const b = el("button", "opt" + (a.agent === data.agent && !trashOn ? " on" : ""));
|
||||
const wrap = el("div", "sm-switch");
|
||||
const tabs = el("div", "segs regions sm-agents");
|
||||
for (const x of data?.agents || []) {
|
||||
const on = x.agent === data.agent && !trashOn;
|
||||
const b = el("button", "opt" + (on ? " on" : ""));
|
||||
b.type = "button";
|
||||
b.append(icon(a.icon), el("span", "", a.name), el("em", "", String(a.count)));
|
||||
b.onclick = () => {
|
||||
if (a.agent === data.agent && !trashOn) return;
|
||||
trashOn = false;
|
||||
agent = a.agent;
|
||||
try { localStorage.setItem("magpie.sessionsAgent", a.agent); } catch {}
|
||||
picked.clear();
|
||||
detail = "";
|
||||
data = { ...data, agent: a.agent, sessions: null };
|
||||
draw();
|
||||
load(a.agent);
|
||||
};
|
||||
seg.append(b);
|
||||
b.setAttribute("aria-pressed", String(on));
|
||||
b.append(icon(x.icon), el("span", "", x.name), el("em", "", String(x.count)));
|
||||
b.onclick = () => chooseAgent(x.agent);
|
||||
tabs.append(b);
|
||||
}
|
||||
h.append(seg, el("span", "grow"));
|
||||
const a = current();
|
||||
const pick = el("button", "sess-pick sm-agent-pick");
|
||||
pick.type = "button";
|
||||
pick.hidden = !a;
|
||||
pick.disabled = (data?.agents.length || 0) < 2;
|
||||
pick.setAttribute("aria-haspopup", "menu");
|
||||
pick.setAttribute("aria-expanded", "false");
|
||||
if (a) {
|
||||
pick.title = a.name;
|
||||
pick.append(icon(a.icon), el("span", "", a.name), el("em", "", String(a.count)), svg(CHEV, 11, 1.6));
|
||||
pick.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (pick.classList.contains("open")) return closeProtoMenu();
|
||||
const opts = data.agents.map((x) => ({ v: x.agent, name: x.name, note: String(x.count), literalName: true }));
|
||||
openProtoMenu(pick, opts, data.agent, (name) => {
|
||||
pick.focus({ preventScroll: true });
|
||||
chooseAgent(name);
|
||||
}, "Agent", "sm-agent-menu");
|
||||
};
|
||||
}
|
||||
wrap.append(tabs, pick);
|
||||
h.append(wrap);
|
||||
const q = el("input", "sess-filter sm-filter");
|
||||
q.type = "search";
|
||||
q.spellcheck = false;
|
||||
|
||||
@@ -685,6 +685,18 @@ Trash lists what went and Restore posts its key; an agent whose sessions
|
||||
magpie can't delete (OpenCode) shows no delete. No left-border accent, every
|
||||
string in Chinese. Chromium and WebKit, English and Chinese, API faked.
|
||||
|
||||
`sessions-toolbar.test.cjs` uses ten agents to check that fitting tabs stay
|
||||
visible at 1800px, while 900, 660 and 320px windows use a compact agent menu.
|
||||
Resizing keeps the current agent; search and Trash fit, the menu fits the
|
||||
narrow window, arrow keys choose, Escape returns focus, and an outside click
|
||||
closes it. Choosing an agent leaves Trash and keeps keyboard focus through
|
||||
loading; 150% CSS zoom switches to the menu and back. Light and dark themes,
|
||||
English and Chinese, Chromium and WebKit, API faked. Run it with:
|
||||
|
||||
```sh
|
||||
node --test internal/gui/tests/sessions-toolbar.test.cjs
|
||||
```
|
||||
|
||||
`routing-wb-refused.test.cjs` opens a request WorkBuddy refused "from an
|
||||
unapproved channel" (Codex's system prompt, #182) on the Routing page: the
|
||||
vendor's words are given without the hint the gateway adds, and the hint is
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Sessions with many agents: tabs switch in one click when they fit, a compact
|
||||
// menu takes over when they don't, and both keep the current agent visible.
|
||||
// Search and Trash fit a narrow window. English and Chinese, Chromium and WebKit.
|
||||
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 agents = [
|
||||
["qodercn", "Qoder CN", "qoder", 426], ["qoder", "Qoder", "qoder", 102],
|
||||
["pi", "Pi", "pi", 89], ["claude", "Claude Code", "claudecode-color", 83],
|
||||
["deepseek", "DeepSeek Harness", "deepseek-color", 70], ["codex", "Codex", "codex-color", 37],
|
||||
["workbuddy", "WorkBuddy", "workbuddy-color", 24], ["opencode", "OpenCode", "opencode", 11],
|
||||
["zcode", "ZCode", "zcode", 10], ["grokbuild", "Grok Build", "xai", 3],
|
||||
].map(([agent, name, icon, count]) => ({ agent, name, icon, count, deletable: true }));
|
||||
|
||||
function serve(lang, theme) {
|
||||
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:"${theme}",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: { lang, theme } });
|
||||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true, window: true } });
|
||||
if (url.pathname === "/api/sessions/manage") {
|
||||
const agent = url.searchParams.get("agent") || agents[0].agent;
|
||||
const count = agents.find((a) => a.agent === agent).count;
|
||||
const sessions = Array.from({ length: count }, (_, i) => ({
|
||||
agent, id: `${agent}-${i}`, cwd: "/work/app", title: i ? `Session ${i + 1}` : "Fix the session toolbar",
|
||||
start: new Date(Date.now() - (i + 2) * 60e3).toISOString(), last: new Date(Date.now() - (i + 1) * 60e3).toISOString(),
|
||||
models: [], messages: 6, files: 1, size: 4096, deletable: true, resume: `${agent} --resume ${agent}-${i}`,
|
||||
}));
|
||||
return json({ agents, agent, sessions, trash: [] });
|
||||
}
|
||||
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)];
|
||||
const body = await fs.readFile(file).catch(() => null);
|
||||
return body ? route.fulfill({ body, contentType }) : 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}: Sessions switches between fitting tabs and a compact agent menu`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium", ignoreDefaultArgs: ["--hide-scrollbars"] }));
|
||||
t.after(() => browser.close());
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const context = await browser.newContext({ viewport: { width: 900, height: 560 }, reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
// The fixture renders hundreds of sessions, also on Windows WebKit.
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", serve(lang, theme));
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator('#nav [data-view="sessions"]').click();
|
||||
const view = page.locator("#view-sessions");
|
||||
const picker = view.locator(".sm-agent-pick");
|
||||
const tabs = view.locator(".sm-agents");
|
||||
const settle = () => page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await picker.waitFor();
|
||||
await page.waitForLoadState("networkidle");
|
||||
assert((await picker.textContent()).includes("Qoder CN"));
|
||||
assert.equal(await picker.locator("em").textContent(), "426");
|
||||
const filter = view.locator(".sm-filter");
|
||||
await filter.fill("Fix the session toolbar");
|
||||
assert.equal(await view.locator(".row.sm-sess").count(), 1, "search still filters the current agent's sessions");
|
||||
await filter.press("Escape");
|
||||
assert.equal(await view.locator(".row.sm-sess").count(), 426, "Escape clears the session search");
|
||||
await filter.evaluate((e) => e.blur());
|
||||
for (const width of [1800, 900, 660, 320]) {
|
||||
await page.setViewportSize({ width, height: 560 });
|
||||
await settle();
|
||||
if (width === 1800) {
|
||||
assert(await tabs.isVisible(), "a wide window keeps the one-click agent tabs");
|
||||
assert(await picker.isHidden(), "the menu button is hidden when the tabs fit");
|
||||
assert(await tabs.evaluate((e) => e.scrollWidth <= e.clientWidth), "visible tabs have no horizontal scrollbar");
|
||||
await tabs.locator(".on").focus();
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = "1.5"; });
|
||||
await settle();
|
||||
assert(await tabs.isHidden(), "150% text zoom switches to the compact picker");
|
||||
assert(await picker.evaluate((e) => document.activeElement === e), "narrowing moves tab focus to the picker");
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = ""; });
|
||||
await settle();
|
||||
assert(await tabs.isVisible(), "restoring text size returns the fitting tabs");
|
||||
assert(await tabs.locator(".on").evaluate((e) => document.activeElement === e), "widening returns focus to the selected tab");
|
||||
await tabs.locator(".on").evaluate((e) => e.blur());
|
||||
} else {
|
||||
assert(await tabs.isHidden(), "overflowing tabs give way to the menu");
|
||||
assert(await picker.isVisible());
|
||||
}
|
||||
const boxes = await view.locator(".sm-switch, .sm-filter, .sm-trash-btn").evaluateAll((els) => els.map((e) => {
|
||||
const b = e.getBoundingClientRect();
|
||||
return { name: e.className, left: b.left, right: b.right, top: b.top, bottom: b.bottom, width: b.width };
|
||||
}));
|
||||
assert(boxes.every((b) => b.width > 0 && b.left >= 0 && b.right <= width), `${theme} ${width}px: header controls fit: ${JSON.stringify(boxes)}`);
|
||||
if (width === 320) {
|
||||
assert(boxes[1].top >= boxes[0].bottom, "a narrow window puts search below the picker");
|
||||
assert(Math.abs(boxes[1].left - boxes[0].left) < 1, "the narrow search field aligns with the picker");
|
||||
}
|
||||
if (process.env.ARTIFACT_DIR) {
|
||||
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-${theme}-${width}-sessions-toolbar.png`) });
|
||||
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-${theme}-${width}-toolbar-only.png`), clip: { x: 0, y: 0, width, height: width === 320 ? 150 : 126 } });
|
||||
}
|
||||
}
|
||||
await picker.click();
|
||||
const menu = page.locator(".sm-agent-menu");
|
||||
await menu.waitFor();
|
||||
await menu.evaluate((e) => Promise.all(e.getAnimations().map((a) => a.finished.catch(() => {}))));
|
||||
assert.equal(await menu.getByRole("menuitemradio").count(), agents.length);
|
||||
assert.equal(await picker.getAttribute("aria-expanded"), "true");
|
||||
const mb = await menu.boundingBox();
|
||||
assert(mb.x >= 0 && mb.x + mb.width <= 320, "the open menu fits a narrow window");
|
||||
if (process.env.ARTIFACT_DIR) await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-${theme}-menu-sessions-toolbar.png`) });
|
||||
await page.keyboard.press("Escape");
|
||||
await menu.waitFor({ state: "detached" });
|
||||
assert.equal(await picker.getAttribute("aria-expanded"), "false");
|
||||
assert(await picker.evaluate((e) => document.activeElement === e), "Escape returns focus to the picker");
|
||||
await page.setViewportSize({ width: 660, height: 560 });
|
||||
await settle();
|
||||
await picker.press("Enter");
|
||||
await menu.waitFor();
|
||||
await page.keyboard.press("ArrowUp");
|
||||
assert((await page.locator(":focus").textContent()).includes("Grok Build"), "the last agent is reachable with the keyboard");
|
||||
await page.keyboard.press("Enter");
|
||||
await menu.waitFor({ state: "detached" });
|
||||
await page.waitForFunction(() => document.querySelector("#view-sessions .sm-agent-pick")?.textContent.includes("Grok Build") && !document.querySelector("#view-sessions .skeleton"));
|
||||
assert.equal(await picker.locator("em").textContent(), "3");
|
||||
assert(await picker.isVisible(), "the loaded agent stays visible in the picker");
|
||||
assert(await picker.evaluate((e) => document.activeElement === e), "choosing an agent returns focus to the loaded picker");
|
||||
if (process.env.ARTIFACT_DIR) await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-${theme}-selected-sessions-toolbar.png`) });
|
||||
await page.setViewportSize({ width: 1800, height: 560 });
|
||||
await settle();
|
||||
assert((await tabs.locator(".on").textContent()).includes("Grok Build"), "widening keeps the current agent selected");
|
||||
await tabs.locator(".opt").first().click();
|
||||
await page.waitForFunction(() => document.querySelector("#view-sessions .sm-agents .on")?.textContent.includes("Qoder CN") && !document.querySelector("#view-sessions .skeleton"));
|
||||
await page.setViewportSize({ width: 660, height: 560 });
|
||||
await settle();
|
||||
assert((await picker.textContent()).includes("Qoder CN"), "narrowing keeps the agent chosen in the tabs");
|
||||
await view.locator(".sm-trash-btn").click();
|
||||
assert.equal(await view.locator(".sm-trash-btn").getAttribute("aria-pressed"), "true");
|
||||
await picker.click();
|
||||
await menu.getByRole("menuitemradio", { name: /OpenCode/ }).click();
|
||||
await page.waitForFunction(() => document.querySelector("#view-sessions .sm-agent-pick")?.textContent.includes("OpenCode") && !document.querySelector("#view-sessions .skeleton"));
|
||||
assert.equal(await view.locator(".sm-trash-btn").getAttribute("aria-pressed"), "false", "choosing an agent leaves Trash");
|
||||
assert(await view.locator(".sm-filter").isVisible());
|
||||
await picker.click();
|
||||
assert.equal(await menu.getByRole("menuitemradio", { checked: true }).count(), 1, "the menu marks only the current agent");
|
||||
await view.locator(".sm-filter").click();
|
||||
await menu.waitFor({ state: "detached" });
|
||||
await view.locator(".sm-trash-btn").click();
|
||||
await view.locator(".sm-trash-btn").click();
|
||||
assert((await picker.textContent()).includes("OpenCode"), "leaving Trash preserves the current agent");
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user