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:
yetone committed 2026-10-10 10:50:09 +08:00
1 parent ca3394ff80
commit 54675767f1
4 files changed
+197 -5

No files matched your search

+2
View File
@@ -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; }
+49 -4
View File
@@ -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();
}
});
}
}
+1 -1
View File
@@ -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 });