diff --git a/perf/budgets.json b/perf/budgets.json index 8cd8435ab..e8fd13052 100644 --- a/perf/budgets.json +++ b/perf/budgets.json @@ -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, diff --git a/perf/scenarios.ts b/perf/scenarios.ts index 4b54b0910..06a761cc2 100644 --- a/perf/scenarios.ts +++ b/perf/scenarios.ts @@ -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 } } + ).__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, diff --git a/src/features/agents/index.ts b/src/features/agents/index.ts index ccaf35a9f..ce93206ad 100644 --- a/src/features/agents/index.ts +++ b/src/features/agents/index.ts @@ -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); }), diff --git a/src/features/agents/sampleConversation.ts b/src/features/agents/sampleConversation.ts new file mode 100644 index 000000000..b3a0bfa96 --- /dev/null +++ b/src/features/agents/sampleConversation.ts @@ -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); +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5aca76293..b752f7c82 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2440,6 +2440,7 @@ "Stop to Edit": "停止以编辑", "Stopped after {count} matches": "匹配 {count} 处后停止", "Stream Sample Output": "流式输出示例日志", + "Open Sample Conversations": "打开示例会话", "Strikeout": "删除线", "Struct": "结构体", "Struct type": "结构体类型",