sessions: a folder draws its latest 100 sessions and a Show more for the next page, and opening a session draws only that session again (ChildhoodAndy on X)
With thousands of sessions the Sessions page drew every row of the open folder, 17,000 elements for 1,000 sessions, and drew them all again on switching to the page or to another agent, on each keystroke in the filter (every folder opens while filtering: 51,000 elements for 3,000) and on opening a session. Switching and scrolling stuttered. Now an open folder draws a page of 100 and a "Show N more" (data-unrolls) that adds the next page under the rows already drawn. A filter or another agent starts again at a page. A session opened from the Usage page is drawn however far down its folder it is. Opening a session replaces only that item and the one it closes. The folder box and the bar still pick every session, drawn or not. Measured with 3,000/2,500 sessions, before → after: - open the page: WebKit 449 → 159 ms, Chromium 312 → 165 ms - open a session: WebKit 287 → 67 ms - filter keystroke: WebKit 546 → 64 ms - elements drawn: 17,073 → 1,774 Run: sessions-manage-paging.test.cjs (new; Chromium + WebKit, en + zh, 1000px + 380px) and every sessions-*/session-* test, click-scroll and old-webkit, all green.
This commit is contained in:
1 parent
ca3394ff80
commit
54675767f1
4 files changed
+197
-5
No files matched your search
@@ -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; }
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user