Compare commits

...
Author SHA1 Message Date
l0ng-ai 9e4e76f979 fix(agents): keep following a conversation's end unless the user scrolls away
A conversation stopped following its end, and stopped painting its earlier
entries, without the user scrolling: every scroll event recomputed "at the
end" from the offset, and WebKit moves the offset back on its own while rows
lay out (and the conversation can grow before the event of a scroll to the
end arrives). Switching tabs back showed it scrolled up with the rest never
loaded.

Only scrolling by the user (wheel, touch, keys, the scrollbar, Find) leaves
the end now; other scroll events while following go back to the end.
2026-11-09 12:26:44 +08:00
3 changed files with 59 additions and 10 deletions

No files matched your search

+16 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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 });