From 92b527ab0e1eb7caa1352be8cde9734eedb00279 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:27:49 +0800 Subject: [PATCH] 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. --- perf/scenarios.ts | 24 +++++++++++++++-------- src/features/agents/SessionEditor.tsx | 21 ++++++++++++++++++-- src/features/agents/sessionState.test.tsx | 24 +++++++++++++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/perf/scenarios.ts b/perf/scenarios.ts index feea3b968..4e6473cbe 100644 --- a/perf/scenarios.ts +++ b/perf/scenarios.ts @@ -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(); diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 06c3e2732..2dbe67587 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -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 ( -
+
{finding && }
{ 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 diff --git a/src/features/agents/sessionState.test.tsx b/src/features/agents/sessionState.test.tsx index 9d69661d9..ba1c3953f 100644 --- a/src/features/agents/sessionState.test.tsx +++ b/src/features/agents/sessionState.test.tsx @@ -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(); + 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(); 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(); 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 });