fix(agents): leave a conversation's end for scrolls no layout caused (#2283)

Following the end snapped back any scroll away from it that didn't follow a
wheel, touch, key or pointer press. A scroll without one, like dragging the
scrollbar thumb where no pointer event reaches the page or a programmatic
jump, was undone. The offset WebKit moves back on its own comes with a change
of the content height while rows lay out; only such a scroll keeps following
now, any other leaves the end.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 14:19:47 +08:00
1 джерело 502cc348ba
коміт 40be4e4c17
2 files changed
+25 -3

No files matched your search

+7 -2
Переглянути файл
@@ -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;
+18 -1
Переглянути файл
@@ -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(<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 });
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(<SessionEditor input={input("A")} />);
expect(await screen.findByText("Answer A")).toBeInTheDocument();