diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 2dbe67587..de42d35a6 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -554,8 +554,11 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o 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. + // that leaves the end. WebKit also moves the offset back on its own while rows lay out, + // which comes with a change of the content height; a scroll without one is meant too (the + // scrollbar thumb, a jump), whatever moved it. const userScrolled = useRef(-Infinity); + const seenHeight = useRef(0); const markUserScroll = useCallback(() => { userScrolled.current = performance.now(); }, []); @@ -723,7 +726,9 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o onScroll={(e) => { const el = e.currentTarget; const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 40; - const byUser = dragging.current || performance.now() - userScrolled.current < 500; + const laidOut = el.scrollHeight !== seenHeight.current; + seenHeight.current = el.scrollHeight; + const byUser = dragging.current || performance.now() - userScrolled.current < 500 || !laidOut; 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; diff --git a/src/features/agents/sessionState.test.tsx b/src/features/agents/sessionState.test.tsx index ba1c3953f..4b7397e2e 100644 --- a/src/features/agents/sessionState.test.tsx +++ b/src/features/agents/sessionState.test.tsx @@ -362,7 +362,10 @@ describe("scrolling", () => { 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. + el.scrollTop = 600; + fireEvent.scroll(el); + // The conversation grew after it was scrolled to the end, or WebKit moved the offset back + // while rows laid out: both change the content height. height = 1300; el.scrollTop = 600; fireEvent.scroll(el); @@ -376,6 +379,20 @@ describe("scrolling", () => { expect(await screen.findByRole("button", { name: "Jump to Latest" })).toBeInTheDocument(); }); + it("leaves the end for a scroll that no layout caused, as dragging the scrollbar thumb", 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 }); + el.scrollTop = 600; + fireEvent.scroll(el); + el.scrollTop = 0; + fireEvent.scroll(el); + expect(el.scrollTop).toBe(0); + 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();