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();