diff --git a/internal/gui/assets/sessions.css b/internal/gui/assets/sessions.css index 7d5a0f46..d6276722 100644 --- a/internal/gui/assets/sessions.css +++ b/internal/gui/assets/sessions.css @@ -87,3 +87,5 @@ .sm-record-sub { font-size: 11.5px; color: var(--muted); line-height: 1.45; } .sm-record-clear { flex: none; margin-left: auto; display: inline-flex; align-items: center; gap: 5px; } .sm-record-clear:hover:not(:disabled) { color: var(--red); background: var(--red-soft); } +/* a folder's next page of sessions, under the ones drawn */ +.sm-group .sm-more { margin: 4px 8px 6px; } diff --git a/internal/gui/assets/sessions.js b/internal/gui/assets/sessions.js index 1f035cd2..bf38a0cc 100644 --- a/internal/gui/assets/sessions.js +++ b/internal/gui/assets/sessions.js @@ -28,6 +28,15 @@ let fitObserver = null; let focus = null; // { agent, id, until }: a session to bring into sight once drawn let recordingBusy = false; + // An open folder draws its latest SM_PAGE sessions and a "Show N more" + // that adds the next page where it is (ChildhoodAndy on X: with thousands + // of sessions, switching to the page or to another agent, a keystroke in + // the filter and opening a session each drew every row again, 17,000 + // elements for 1,000 sessions, and scrolling stuttered). drawnIn is how + // many of each folder's sessions are drawn, by cwd; it starts again at a + // page when the agent or the filter changes. + const SM_PAGE = 100; + const drawnIn = new Map(); // A session seen only through magpie's gateway has no folder magpie knows // (its agent's files are on another computer, in a container or under @@ -243,8 +252,8 @@ q.placeholder = t("Filter sessions"); q.value = query; q.hidden = trashOn; - q.oninput = () => { query = q.value; redrawList(); }; - q.onkeydown = (e) => { if (e.key === "Escape" && q.value) { e.stopPropagation(); q.value = ""; query = ""; redrawList(); } }; + q.oninput = () => { query = q.value; drawnIn.clear(); redrawList(); }; + q.onkeydown = (e) => { if (e.key === "Escape" && q.value) { e.stopPropagation(); q.value = ""; query = ""; drawnIn.clear(); redrawList(); } }; const tr = el("button", "text sm-trash-btn" + (trashOn ? " on" : "")); tr.type = "button"; tr.setAttribute("aria-pressed", String(trashOn)); @@ -455,6 +464,7 @@ if (openedFor !== data.agent) { openedFor = data.agent; opened.clear(); + drawnIn.clear(); if (list[0]) opened.add(groupOf(list[0])); } const groups = new Map(); @@ -508,12 +518,40 @@ r.title = path; g.append(r); - if (open) for (const s of items) g.append(item(s)); + if (open) { + // a page, or as many as were drawn before, and down to the session + // opened to its details (from the Usage page's list, #752) + const at = detail ? items.findIndex((s) => s.id === detail) : -1; + const n = Math.min(items.length, Math.max(drawnIn.get(cwd) || SM_PAGE, at + 1)); + for (const s of items.slice(0, n)) g.append(item(s)); + if (n < items.length) g.append(moreRow(cwd, items, n)); + } tree.append(g); } toFocus(); } + // moreRow adds the folder's next page of sessions above itself, leaving + // the rows drawn as they are, so what the reader looks at doesn't move + function moreRow(cwd, items, n) { + drawnIn.set(cwd, n); + const b = el("button", "text sess-page-more sm-more"); + b.type = "button"; + b.dataset.unrolls = ""; // it goes down with the rows it opens + const label = () => t("Show {n} more", { n: Math.min(SM_PAGE, items.length - n) }); + b.textContent = label(); + b.onclick = (e) => { + e.stopPropagation(); + const next = Math.min(items.length, n + SM_PAGE); + b.before(...items.slice(n, next).map(item)); + n = next; + drawnIn.set(cwd, n); + if (n >= items.length) b.remove(); + else b.textContent = label(); + }; + return b; + } + function item(s) { const a = current(); const wrap = el("div", "sess-item sm-item" + (detail === s.id ? " open" : "")); @@ -580,10 +618,17 @@ del.onclick = (e) => { e.stopPropagation(); askDelete([s.id]); }; r.append(del); } + // only the session opened and the one closed are drawn again r.onclick = () => { if (window.getSelection()?.toString()) return; + const was = detail; detail = detail === s.id ? "" : s.id; - redrawList(); + if (was && was !== s.id) { + const old = page.querySelector(`.row.sm-sess[data-id="${CSS.escape(was)}"]`)?.closest(".sm-item"); + const prev = data.sessions.find((x) => x.id === was); + if (old && prev) old.replaceWith(item(prev)); + } + wrap.replaceWith(item(s)); }; wrap.append(r); if (detail === s.id) wrap.append(details(s)); diff --git a/internal/gui/tests/sessions-manage-paging.test.cjs b/internal/gui/tests/sessions-manage-paging.test.cjs new file mode 100644 index 00000000..68cccfb3 --- /dev/null +++ b/internal/gui/tests/sessions-manage-paging.test.cjs @@ -0,0 +1,145 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// The Sessions page with a history of thousands (ChildhoodAndy on X: switching +// to it, or to another agent, and scrolling it stuttered). An open folder draws +// its latest 100 sessions and a "Show 100 more" that adds the next page under +// them, leaving what is drawn, and where the reader is, as it was; a filter and +// another agent start again at a page. Opening a session draws that session +// again, not the list. A folder's box and the bar's still pick every session, +// drawn or not, and a session opened from the Usage page is drawn however far +// down its folder it is. English and Chinese, Chromium and WebKit, wide and +// narrow. +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 N = 250; // two pages and a half in the big folder +const at = (min) => new Date(Date.now() - min * 60e3).toISOString(); +const sessions = (agent) => Array.from({ length: agent === "claude" ? N + 3 : 140 }, (_, i) => ({ + agent, id: `${agent}-${i}`, cwd: i < N || agent !== "claude" ? "/work/big" : "/work/small", title: `${agent} task ${i}`, + start: at(i + 5), last: at(i + 1), input: 1000, output: 100, cost: 0.1, priced: true, + models: [{ model: "claude-sonnet-5", input: 1000, output: 100, cost: 0.1, priced: true }], + messages: 6, files: 1, size: 4096, deletable: true, resume: `${agent} --resume ${agent}-${i}`, +})); +const agents = [{ agent: "claude", name: "Claude Code", icon: "claudecode-color", count: N + 3, deletable: true }, { agent: "codex", name: "Codex", icon: "openai", count: 140, deletable: true }]; +const words = { + en: { more: "Show 100 more", rest: "Show 50 more", picked: `${N} selected`, all: `${N + 3} selected` }, + zh: { more: "显示其余 100 个", rest: "显示其余 50 个", picked: `已选 ${N} 个`, all: `已选 ${N + 3} 个` }, +}; + +function serve(lang) { + 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:"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({ agents: [], profiles: [], settings: { lang, theme: "light" } }); + 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") === "codex" ? "codex" : "claude"; + return json({ agents, agent, sessions: sessions(agent), 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}: a big folder of sessions is drawn a page at a time`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const w = words[lang]; + for (const width of [1000, 380]) { + const page = await (await browser.newContext({ viewport: { width, height: 640 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(10000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message + " " + e.stack)); + await page.route("**/*", serve(lang)); + await page.goto("http://magpie.test/"); + await page.locator('#nav [data-view="sessions"]').click(); + const view = page.locator("#view-sessions"); + const rows = view.locator(".row.sm-sess"); + const more = view.locator(".sm-more"); + await rows.first().waitFor(); + assert.equal(await rows.count(), 100, "the open folder draws one page"); + assert.equal(await more.textContent(), w.more); + const box = await more.boundingBox(); + assert(box.x >= 0 && box.x + box.width <= width, `${width}px: Show more fits`); + + // the next page goes under the rows drawn; they and the scroll stay + await rows.nth(99).scrollIntoViewIfNeeded(); + await rows.first().evaluate((e) => { e.kept = true; }); + const y = () => rows.nth(99).evaluate((e) => Math.round(e.getBoundingClientRect().top)); + const before = await y(); + // clicked where it is, as the reader does, without scrolling to it + await more.evaluate((e) => e.click()); + assert.equal(await rows.count(), 200); + assert.equal(await y(), before, "Show more doesn't move the rows in sight"); + assert(await rows.first().evaluate((e) => e.kept === true), "the rows drawn are kept, not drawn again"); + assert.equal(await more.textContent(), w.rest); + await more.evaluate((e) => e.click()); + assert.equal(await rows.count(), N); + assert.equal(await more.count(), 0, "the last page leaves no Show more"); + + // opening a session draws it alone + // back to the top as the reader goes there: a scroll of code's own + // is put back (WebKit) + await page.mouse.move(width / 2, 320); + await page.mouse.wheel(0, -40000); + await page.waitForFunction(() => document.querySelector("#view-sessions").scrollTop === 0); + await rows.nth(5).click(); + await view.locator(".sm-item.open .sess-detail").waitFor(); + assert(await rows.first().evaluate((e) => e.kept === true), "opening a session leaves the other rows"); + assert.equal(await view.locator(".sm-item.open .row.sm-sess").getAttribute("data-id"), "claude-5"); + await rows.nth(7).evaluate((e) => e.click()); // under the details, out of sight in a short window + assert.equal(await view.locator(".sm-item.open").count(), 1, "the one opened before closes"); + assert.equal(await view.locator(".sm-item.open .row.sm-sess").getAttribute("data-id"), "claude-7"); + await view.locator('.row.sm-sess[data-id="claude-7"]').evaluate((e) => e.click()); + assert.equal(await view.locator(".sm-item.open").count(), 0); + + // a filter starts again at a page; clearing it keeps a page + const filter = view.locator(".sm-filter"); + await filter.fill("task"); + assert.equal(await rows.count(), 100 + 3, "every folder opens to a page while filtering"); + await filter.fill("task 249"); + assert.equal(await rows.count(), 1); + await filter.fill(""); + assert.equal(await rows.count(), 100); + + // the folder's box and the bar pick every session, drawn or not + await view.locator(".sm-folder-check").first().check(); + assert.equal(await view.locator(".sm-bar .sm-count").textContent(), w.picked); + await view.locator(".sm-bar .sm-check").check(); + assert.equal(await view.locator(".sm-bar .sm-count").textContent(), w.all); + await view.locator(".sm-bar .sm-check").uncheck(); + + // another agent starts at a page, and so does coming back + await page.evaluate(() => window.openSessionOnPage({ agent: "codex" })); + await view.locator('.row.sm-sess[data-id^="codex"]').first().waitFor(); + assert.equal(await rows.count(), 100); + // a session opened from the Usage page, far down its folder, is drawn + await page.evaluate(() => window.openSessionOnPage({ agent: "claude", id: "claude-180", cwd: "/work/big" })); + await view.locator('.sm-item.open .row.sm-sess[data-id="claude-180"]').waitFor(); + assert.equal(await rows.count(), 181); + assert.equal(await more.textContent(), lang === "en" ? "Show 69 more" : "显示其余 69 个"); + + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + await page.mouse.wheel(0, 40000); + await page.waitForTimeout(300); + await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-${width}-sessions-manage-paging.png`) }); + } + assert.deepEqual(errors, []); + await page.context().close(); + } + }); + } +} diff --git a/internal/gui/tests/sessions-toolbar.test.cjs b/internal/gui/tests/sessions-toolbar.test.cjs index 5ae418ad..6a5c644b 100644 --- a/internal/gui/tests/sessions-toolbar.test.cjs +++ b/internal/gui/tests/sessions-toolbar.test.cjs @@ -71,7 +71,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", 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"); + assert.equal(await view.locator(".row.sm-sess").count(), 100, "Escape clears the session search, back to the folder's first page"); await filter.evaluate((e) => e.blur()); for (const width of [1800, 900, 660, 320]) { await page.setViewportSize({ width, height: 560 });