feat(gui): collapse overflowing Sessions agent tabs into a picker (#461)

This commit is contained in:
TryWorld authored and GitHub committed 2026-10-02 02:25:17 +08:00
1 parent 25194cbc0d
commit 876bec1b89
5 files changed
+267 -21

No files matched your search

+2
View File
@@ -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(); };
+20 -4
View File
@@ -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; }
}
+68 -17
View File
@@ -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;
+12
View File
@@ -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();
}
});
}
}