Файли
openidea/e2e/agentScroll.spec.ts
T
826e5837b1 feat(agents): chat-style conversation view with folded tool runs, tags, jump-to-latest and retract (#2067)
* feat(agents): chat-style conversation view with folded tool runs, tags, jump-to-latest and retract (#1980)

- Lay the conversation out as chat: user messages on the right, agent replies on the left, each with an author row, a card, actions in the header and a time/duration footer.
- Fold consecutive reasoning and finished tool calls into one collapsible 'Reasoning - N tool calls' row with +added / -removed counts; running tools stay expanded.
- Render @file and /skill references in message text as distinct tags.
- Load earlier entries in batches without moving the viewport, offer 'Jump to Latest' when scrolled away, and defer loading while the scrollbar is dragged.
- Add 'Retract Message': stop the agent, drop that turn and every later one, and put the original text/images back in the composer.
- Drop the catalog entry for the replaced duration tooltip.

* fix(agents): preserve conversation scrolling and retract state

---------

Co-authored-by: SunZhiQiang <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-11-08 06:57:09 +08:00

135 рядки
4.9 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import type { AgentEntry, AgentTask } from "../src/bindings";
const task: AgentTask = {
id: "long-chat",
title: "Long conversation",
status: "ready",
isolation: "local",
provider: "codex",
model: null,
cwd: "/dev/acme-api",
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: 0,
updatedMs: 0,
config: [],
commands: [],
usage: null,
queue: [],
};
const entries: AgentEntry[] = Array.from({ length: 80 }, (_, turn): AgentEntry[] => [
{ type: "user", text: `Request ${turn}`, attachments: [], steered: false },
...["read", "execute", "edit"].map((kind): AgentEntry => ({
type: "tool",
id: `${turn}-${kind}`,
kind,
title: `${kind} src/module-${turn}.ts`,
status: "completed",
locations: [],
output: Array.from({ length: 80 }, (_, line) => `output ${turn}:${line}`).join("\n"),
diffs: [],
})),
{ type: "thought", text: `Checking module ${turn}.\n\n${"Details of the investigation. ".repeat(30)}` },
{
type: "agent",
text: `Reply ${turn}\n\n- Checked the module.\n- Updated the implementation.\n\n\`\`\`text\nmodule-${turn}\n src/\n tests/\n\`\`\``,
},
]).flat();
async function openHistory(page: Page) {
await page.goto("/");
await page.getByRole("tree", { name: "Project" }).waitFor();
await page.evaluate(
async ({ task, entries }) => {
const path = "/src/features/agents/store.ts";
const store = await import(path);
store.useAgents.setState({
loaded: true,
error: null,
providers: [],
tasks: new Map([[task.id, task]]),
entries: new Map([[task.id, entries]]),
unread: new Set(),
docked: null,
});
store.openTaskInEditorLocation(task.id);
},
{ task, entries },
);
const stream = page.locator("[class*='_stream_']:visible");
await expect(stream.locator(":scope > *")).toHaveCount(80 * 3);
await page.evaluate(() => document.fonts.ready);
return stream;
}
for (const selected of [false, true]) {
test(`keeps long-chat scroll geometry stable while jumping through history (selected: ${selected})`, async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const stream = await openHistory(page);
const result = await stream.evaluate(async (el, selected) => {
let text = "";
if (selected) {
const range = document.createRange();
range.selectNodeContents(el);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
text = window.getSelection()!.toString();
}
await new Promise(requestAnimationFrame);
const initialHeight = el.scrollHeight;
const samples: Array<{ height: number; drift: number }> = [];
// A thumb can jump into previously unvisited rows in either direction.
for (const fraction of [0, 0.75, 0.25, 1, 0.5, 0.1, 0.9, 0]) {
el.scrollTop = fraction * (initialHeight - el.clientHeight);
const target = el.scrollTop;
await new Promise(requestAnimationFrame);
await new Promise(requestAnimationFrame);
samples.push({ height: el.scrollHeight, drift: Math.abs(el.scrollTop - target) });
}
return { initialHeight, samples, text, after: window.getSelection()!.toString() };
}, selected);
expect(result.initialHeight).toBeGreaterThan(10_000);
for (const sample of result.samples) {
expect(Math.abs(sample.height - result.initialHeight)).toBeLessThanOrEqual(1);
expect(sample.drift).toBeLessThanOrEqual(1);
}
if (selected) {
expect(result.text).toContain("Request 0");
expect(result.text).toContain("Reply 79");
expect(result.after).toBe(result.text);
await stream.focus();
await page.keyboard.press("ControlOrMeta+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(result.text);
await page.evaluate(() => window.getSelection()!.removeAllRanges());
}
});
}
test("opens a folded process with the keyboard and jumps back to the latest reply", async ({ page }) => {
const stream = await openHistory(page);
await stream.evaluate((el) => {
el.scrollTop = 0;
});
const jump = page.getByRole("button", { name: "Jump to Latest" });
await expect(jump).toBeVisible();
const fold = stream.locator("[class*='_processRun_']").first();
const summary = fold.locator(":scope > summary");
await summary.focus();
await page.keyboard.press("Enter");
await expect(fold).toHaveAttribute("open", "");
await expect(fold.getByText("read src/module-0.ts", { exact: true })).toBeVisible();
await page.keyboard.press("Space");
await expect(fold).not.toHaveAttribute("open", "");
await jump.focus();
await page.keyboard.press("Enter");
await expect(jump).toBeHidden();
await expect.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThan(40);
});