fix(agents): keep following a conversation's end unless the user scrolls away (#2269)

Only user scrolling (wheel, touch, keys, the scrollbar, Find) leaves the end of an agent conversation; offsets WebKit moves back on its own while rows lay out no longer stop it following and painting its history.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 12:27:49 +08:00
1 джерело 0e81c6aeb1
коміт 92b527ab0e
3 files changed
+59 -10

No files matched your search

+16 -8
Переглянути файл
@@ -491,17 +491,23 @@ const agentTabs: Scenario = {
).__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.
// Both conversations get their earlier entries painted in background batches, also the
// first one, hidden behind the second as soon as it opened.
await h.page.waitForFunction(
() =>
[...document.querySelectorAll("[data-message]")]
.map((m) => m.parentElement!)
.filter((stream, i, all) => all.indexOf(stream) === i && stream.textContent!.includes("Turn 0:")).length ===
2,
undefined,
{ timeout: 30_000, polling: 200 },
);
// Rows painted while hidden are laid out the first time their conversation shows.
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.locator(shown(n)).waitFor();
await h.page.waitForTimeout(300);
}
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) => {
@@ -540,6 +546,8 @@ const agentTabs: Scenario = {
const row = [...stream.children].find((r) => r.getBoundingClientRect().bottom > top + 200)!;
const from = row.getBoundingClientRect().top;
const scrolled = stream.scrollTop;
// A wheel step: the user scrolls, so the conversation stops following its end.
stream.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -120 }));
stream.scrollTop -= 120;
const by = scrolled - stream.scrollTop;
await frame();
+19 -2
Переглянути файл
@@ -553,6 +553,12 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
// Keep a process run intact so prepending never replaces the anchored row.
while (entries && start > 0 && isProcessEntry(entries[start]!) && isProcessEntry(entries[start - 1]!)) start--;
const dragging = useRef(false);
// When the user last scrolled the conversation (wheel, touch, keys, the scrollbar, Find):
// only that leaves the end. WebKit also moves the offset back on its own while rows lay out.
const userScrolled = useRef(-Infinity);
const markUserScroll = useCallback(() => {
userScrolled.current = performance.now();
}, []);
// Earlier entries are prepended above the view: remember where the first painted row is
// so the content stays put and the scrollbar doesn't jump under the pointer.
const anchor = useRef<{ el: Element; top: number } | null>(null);
@@ -701,7 +707,13 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
[task, turns, replies, times, lastIndex, busy, onOpenSubagent, onDiagramDrawn],
);
return (
<div className={styles.streamArea}>
<div
className={styles.streamArea}
onWheel={markUserScroll}
onTouchMove={markUserScroll}
onKeyDown={markUserScroll}
onPointerDown={markUserScroll}
>
{finding && <FindBar streamRef={ref} focus={findFocus} onClose={onCloseFind} />}
<div
ref={ref}
@@ -710,7 +722,12 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
tabIndex={-1}
onScroll={(e) => {
const el = e.currentTarget;
position.current = { top: el.scrollTop, stick: el.scrollHeight - el.scrollTop - el.clientHeight < 40 };
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
const byUser = dragging.current || performance.now() - userScrolled.current < 500;
const stick = atEnd || (position.current.stick && !byUser);
// Still following: back to the end the offset was moved away from.
if (stick && !atEnd) el.scrollTop = el.scrollHeight;
position.current = { top: el.scrollTop, stick };
positions.set(key, position.current);
setAway(!position.current.stick);
// Reached the top of what is painted: fetch the earlier entries, but only
+24
Переглянути файл
@@ -305,6 +305,7 @@ describe("scrolling", () => {
let height = 1000;
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
fireEvent.wheel(el);
el.scrollTop = 100;
fireEvent.scroll(el);
expect(screen.getByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
@@ -354,12 +355,34 @@ describe("scrolling", () => {
view.unmount();
});
it("keeps following the end when the offset moves back without the user scrolling", async () => {
render(<SessionEditor input={input("A")} />);
expect(await screen.findByText("Answer A")).toBeInTheDocument();
const el = stream();
let height = 1000;
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
// The conversation grew after it was scrolled to the end, or WebKit moved the offset back.
height = 1300;
el.scrollTop = 600;
fireEvent.scroll(el);
expect(el.scrollTop).toBe(1300);
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
// Scrolling up with the wheel leaves the end.
fireEvent.wheel(el);
el.scrollTop = 600;
fireEvent.scroll(el);
expect(el.scrollTop).toBe(600);
expect(await screen.findByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
});
it("offers one click back to the latest message after scrolling up", async () => {
render(<SessionEditor input={input("A")} />);
expect(await screen.findByText("Answer A")).toBeInTheDocument();
const el = stream();
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
fireEvent.wheel(el);
el.scrollTop = 100;
fireEvent.scroll(el);
const jump = await screen.findByRole("button", { name: "Jump to Latest" });
@@ -784,6 +807,7 @@ describe("session state across tasks and hosts", () => {
});
const view = render(<SessionEditor input={input("A")} />);
await screen.findByText("Answer A");
fireEvent.wheel(stream());
stream().scrollTop = stick ? height - viewport : 230;
fireEvent.scroll(stream());
expect(useAgents.getState().scrollPositions.get("A")).toEqual({ top: stick ? 1600 : 230, stick });