perf: add a scenario for switching between and scrolling long agent conversations (#2259)

Adds the agentTabs perf scenario: two 2,100-entry agent conversations switched with Ctrl+Tab and scrolled, with budgets on DOM size, rows skipping rendering off screen, switch latency, scroll jumps and frame pacing.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 11:36:35 +08:00
1 джерело 8c21a1e088
коміт 36fae164a8
5 files changed
+240

No files matched your search

+10
Переглянути файл
@@ -73,6 +73,16 @@
"tabs.openSmallMs": 100,
"tabs.switchP95Ms": 33,
"agentTabs.conversationNodes": 20000,
"agentTabs.rowsAlwaysRendered": 0,
"agentTabs.switchP95Ms": 66,
"agentTabs.switchMainThreadMs": 50,
"agentTabs.switchTbtMs": 50,
"agentTabs.scrollJumps": 0,
"agentTabs.scrollFrameP95Ms": 17.5,
"agentTabs.scrollDroppedFramesPct": 2,
"agentTabs.scrollTbtMs": 50,
"popups.findActionOpenMs": 50,
"popups.findActionQueryMs": 120,
"popups.findActionKeyP95Ms": 33,
+105
Переглянути файл
@@ -459,6 +459,110 @@ const tabs: Scenario = {
},
};
/**
* Two agent tasks with long conversations (150 turns, 2,100 entries) in editor tabs:
* switching between them with the Switcher (Ctrl+Tab) shows a kept-alive conversation
* again, which must not restyle its whole history; then scrolling up through one.
* Chromium anchors scrolling on its own (WebKit doesn't), so anchoring is turned off
* here: rows that take their real height only once scrolled into view would jump.
*/
const agentTabs: Scenario = {
id: "agentTabs",
title: "Switch between and scroll long agent conversations",
async run(h) {
await h.page.addStyleTag({ content: "* { overflow-anchor: none !important; }" });
await h.page.evaluate(() =>
(
window as never as { __openideaPerf: { runAction(id: string, arg?: unknown): Promise<boolean> } }
).__openideaPerf.runAction("agents.openSampleConversations", { tasks: 2, turns: 150 }),
);
const shown = (n: number) => `[data-editor-focus] span[title="Sample task ${n}"]`;
// Each conversation shown until its earlier entries, painted in background batches, are all in.
for (const n of [1, 2]) {
await h.page.getByRole("tab", { name: `Sample task ${n}` }).click();
await h.page.waitForFunction(
(selector) =>
document.querySelector(selector)?.closest("[data-editor-focus]")?.textContent?.includes("Turn 0:"),
shown(n),
{ timeout: 30_000, polling: 200 },
);
}
await h.page.waitForTimeout(500);
// WebKit restyles a hidden conversation in full when it is shown again (Chromium keeps its
// style): what bounds that there is the history's DOM, and rows skipping rendering off screen.
const { conversationNodes, rowsAlwaysRendered } = await h.page.evaluate((selector) => {
const conversation = document.querySelector(selector)!.closest("[data-editor-focus]")!;
const rows = [...conversation.querySelector("[data-message]")!.parentElement!.children];
return {
conversationNodes: conversation.querySelectorAll("*").length,
rowsAlwaysRendered: rows.filter((r) => getComputedStyle(r).contentVisibility !== "auto").length,
};
}, shown(2));
const t0 = await h.now();
const before = await h.counters();
const switches: number[] = [];
for (let i = 0; i < 10; i++) {
switches.push(await h.measure(() => h.pressAction("navigate.switcher"), shown(i % 2 ? 2 : 1)));
await h.page.waitForTimeout(150);
}
const work = Harness.delta(before, await h.counters());
const switchTbtMs = totalBlockingTime(await h.longTasksSince(t0));
// Scrolling up from the end, a step at a time: how far the text under the pointer moves
// beyond the scroll itself.
const scrollJumps = await h.page.evaluate(async (selector) => {
const stream = document
.querySelector(selector)!
.closest("[data-editor-focus]")!
.querySelector("[data-message]")!.parentElement!;
const frame = () => new Promise(requestAnimationFrame);
stream.scrollTop = stream.scrollHeight;
await frame();
await frame();
const top = stream.getBoundingClientRect().top;
let jumps = 0;
for (let i = 0; i < 100 && stream.scrollTop > 0; i++) {
const row = [...stream.children].find((r) => r.getBoundingClientRect().bottom > top + 200)!;
const from = row.getBoundingClientRect().top;
const scrolled = stream.scrollTop;
stream.scrollTop -= 120;
const by = scrolled - stream.scrollTop;
await frame();
await frame();
if (Math.abs(row.getBoundingClientRect().top - from - by) > 2) jumps++;
}
stream.scrollTop = stream.scrollHeight;
return jumps;
}, shown(2));
const refresh = await h.refreshMs();
const box = (await h.page.locator("[data-editor-focus]").boundingBox())!;
await h.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await h.page.waitForTimeout(200);
const t1 = await h.now();
await h.inPage((p) => p.startFrames());
for (let i = 0; i < 120; i++) {
await h.page.mouse.wheel(0, -120);
await h.page.waitForTimeout(16);
}
await h.page.waitForTimeout(200);
const scroll = frameSummary(await h.inPage((p) => p.stopFrames()), refresh);
return {
conversationNodes,
rowsAlwaysRendered,
...latencySummary(switches, "switch"),
switchMainThreadMs: work.taskMs / switches.length,
switchStyleLayoutMs: work.styleLayoutMs / switches.length,
switchTbtMs,
scrollJumps,
scrollFrameP95Ms: scroll.frameP95Ms,
scrollDroppedFramesPct: scroll.droppedFramesPct,
scrollTbtMs: totalBlockingTime(await h.longTasksSince(t1)),
};
},
};
/** Find Action and Search Everywhere: open, then query → first result painted. */
const popups: Scenario = {
id: "popups",
@@ -606,6 +710,7 @@ export const SCENARIOS: Scenario[] = [
keyRepeat,
scroll,
tabs,
agentTabs,
popups,
toolWindows,
projectTree,
+16
Переглянути файл
@@ -45,6 +45,22 @@ export function activate(): () => void {
sync(useProject.getState().project?.root);
const disposers = [
activateSettingsPages(),
// The performance harness (`perf/`): long conversations to switch between and scroll.
import.meta.env.DEV || import.meta.env.MODE === "perf"
? registerActions([
{
id: "agents.openSampleConversations",
title: "Open Sample Conversations",
category: "Agents",
hidden: true,
run: async ({ arg }) => {
const { tasks = 2, turns = 150 } = (arg ?? {}) as { tasks?: number; turns?: number };
const { openSampleConversations } = await import("./sampleConversation");
openSampleConversations(tasks, turns);
},
},
])
: () => {},
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) sync(s.project?.root);
}),
+108
Переглянути файл
@@ -0,0 +1,108 @@
/**
* Agent tasks with long generated conversations, opened as editor tabs: what the
* performance harness (`perf/`) switches between and scrolls. Dev and perf builds only.
*/
import type { AgentEntry, AgentTask } from "@/bindings";
import { useProject } from "@/platform/project";
import { openTask, useAgents } from "./store";
function task(id: string, title: string, cwd: string): AgentTask {
return {
id,
title,
provider: "claude",
model: null,
status: "ready",
isolation: "worktree",
cwd,
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: null,
updatedMs: null,
config: [],
commands: [],
usage: null,
queue: [],
};
}
/** A turn as agents work: a request, reasoning, a run of tool calls, then a reply with code. */
function turn(cwd: string, i: number): AgentEntry[] {
const file = (k: number) => `${cwd}/src/orders/Order${(i + k) % 40}.kt`;
const tools: AgentEntry[] = Array.from({ length: 10 }, (_, k) => {
// Tool titles as agents report them (commands and paths, not UI text).
const command = k % 3 === 2 ? `Edit ${file(k)}` : k % 3 === 1 ? `Read ${file(k)}` : `grep -rn "order${i}" src`;
return {
type: "tool",
id: `tool-${i}-${k}`,
title: command,
kind: k % 3 === 2 ? "edit" : k % 3 === 1 ? "read" : "execute",
status: "completed",
locations: [],
output: Array.from(
{ length: 12 },
(_, l) => `${file(l)}:${l * 7 + 3}: val order${i} = repository.find(id${l})`,
).join("\n"),
diffs: k % 3 === 2 ? [{ path: file(k), added: 4 + (k % 5), removed: 2 }] : [],
};
});
// What a user and an agent wrote (conversation content, not UI text).
const request = `Turn ${i}: make the order service retry failed payments and cover it with tests`;
const reasoning = `The payment client throws on timeouts; retrying belongs in the service (turn ${i}).`;
return [
{
type: "user",
text: request,
attachments: [],
sentMs: null,
doneMs: null,
},
{
type: "thought",
text: reasoning,
},
...tools,
{
type: "agent",
text: [
`## Turn ${i}`,
"",
`Payments now retry **three times** with backoff before the order fails. Changed \`OrderService.kt\` and added tests:`,
"",
"- `retries` on timeouts, not on declined cards",
"- waits 200 ms, then 400 ms, then 800 ms",
"- logs each attempt with the order id",
"",
"```kotlin",
`suspend fun pay${i}(order: Order): Receipt =`,
" retry(times = 3, initialDelay = 200.milliseconds) {",
" payments.charge(order.id, order.total)",
" }",
"```",
"",
"| Case | Before | After |",
"|---|---|---|",
"| Timeout | failed | retried |",
"| Declined | failed | failed |",
].join("\n"),
},
];
}
/** Opens `count` tasks of `turns` turns each (14 entries a turn), the last one shown. */
export function openSampleConversations(count: number, turns: number) {
const cwd = useProject.getState().project?.root ?? "/";
const entries = Array.from({ length: turns }, (_, i) => turn(cwd, i)).flat();
const s = useAgents.getState();
const tasks = new Map(s.tasks);
const all = new Map(s.entries);
const ids = Array.from({ length: count }, (_, n) => `sample-${n + 1}`);
ids.forEach((id, n) => {
tasks.set(id, task(id, `Sample task ${n + 1}`, cwd));
all.set(id, entries);
});
useAgents.setState({ tasks, entries: all });
for (const id of ids) openTask(id);
}
+1
Переглянути файл
@@ -2440,6 +2440,7 @@
"Stop to Edit": "停止以编辑",
"Stopped after {count} matches": "匹配 {count} 处后停止",
"Stream Sample Output": "流式输出示例日志",
"Open Sample Conversations": "打开示例会话",
"Strikeout": "删除线",
"Struct": "结构体",
"Struct type": "结构体类型",