diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 8d627a5a..53f4f392 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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(); }; diff --git a/internal/gui/assets/sessions.css b/internal/gui/assets/sessions.css index 8588fe1c..fc212abd 100644 --- a/internal/gui/assets/sessions.css +++ b/internal/gui/assets/sessions.css @@ -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; } +} diff --git a/internal/gui/assets/sessions.js b/internal/gui/assets/sessions.js index 0c443a35..174c4be8 100644 --- a/internal/gui/assets/sessions.js +++ b/internal/gui/assets/sessions.js @@ -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; diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 08ec6394..9da8eda8 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -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 diff --git a/internal/gui/tests/sessions-toolbar.test.cjs b/internal/gui/tests/sessions-toolbar.test.cjs new file mode 100644 index 00000000..5ae418ad --- /dev/null +++ b/internal/gui/tests/sessions-toolbar.test.cjs @@ -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(); + } + }); + } +}