Skip to content

Commit d3e7e03

Browse files
fix(ui): preserve WebChat backscroll during streaming (#92622)
* fix(ui): preserve webchat backscroll during streaming * fix(ui): keep forced chat scroll above follow lock --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent 64f9f3c commit d3e7e03

3 files changed

Lines changed: 98 additions & 9 deletions

File tree

ui/src/ui/app-scroll.test.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ function createScrollHost(
5151
chatLastScrollTop: 0,
5252
chatHasAutoScrolled: false,
5353
chatUserNearBottom: true,
54+
chatFollowLocked: false,
5455
chatHeaderControlsHidden: false,
5556
chatNewMessagesBelow: false,
5657
chatIsProgrammaticScroll: false,
@@ -107,10 +108,9 @@ describe("handleChatScroll", () => {
107108
expect(host.chatUserNearBottom).toBe(false);
108109
});
109110

110-
it("sets chatUserNearBottom=false when user scrolled up past one long message (>200px <450px)", () => {
111+
it("sets chatUserNearBottom=false when scrolled past the near-bottom threshold", () => {
111112
const { host } = createScrollHost({});
112-
// distanceFromBottom = 2000 - 1250 - 400 = 350 → old threshold would say "near", new says "near"
113-
// distanceFromBottom = 2000 - 1100 - 400 = 500 → old threshold would say "not near", new also "not near"
113+
// distanceFromBottom = 2000 - 1100 - 400 = 500 → beyond threshold
114114
const event = createScrollEvent(2000, 1100, 400);
115115
handleChatScroll(host, event);
116116
expect(host.chatUserNearBottom).toBe(false);
@@ -232,6 +232,24 @@ describe("scheduleChatScroll", () => {
232232
expect(container.scrollTop).toBe(container.scrollHeight);
233233
});
234234

235+
it("uses force=true on initial load even after a previous follow lock", async () => {
236+
const { host, container } = createScrollHost({
237+
scrollHeight: 2000,
238+
scrollTop: 500,
239+
clientHeight: 400,
240+
});
241+
host.chatUserNearBottom = false;
242+
host.chatFollowLocked = true;
243+
host.chatHasAutoScrolled = false;
244+
245+
scheduleChatScroll(host, true);
246+
await host.updateComplete;
247+
248+
expect(container.scrollTop).toBe(container.scrollHeight);
249+
expect(host.chatFollowLocked).toBe(false);
250+
expect(host.chatNewMessagesBelow).toBe(false);
251+
});
252+
235253
it("sets chatNewMessagesBelow when not scrolling due to user position", async () => {
236254
const { host } = createScrollHost({
237255
scrollHeight: 2000,
@@ -248,6 +266,62 @@ describe("scheduleChatScroll", () => {
248266
expect(host.chatNewMessagesBelow).toBe(true);
249267
});
250268

269+
it("does not re-stick streaming after a user scrolls slightly up near the bottom", async () => {
270+
const { host, container } = createScrollHost({
271+
scrollHeight: 2000,
272+
scrollTop: 1540,
273+
clientHeight: 400,
274+
});
275+
host.chatHasAutoScrolled = true;
276+
host.chatUserNearBottom = true;
277+
host.chatIsProgrammaticScroll = true;
278+
host.chatProgrammaticScrollTarget = 1800;
279+
host.chatLastScrollTop = 1600;
280+
281+
handleChatScroll(host, createScrollEvent(2000, 1540, 400));
282+
283+
expect(host.chatFollowLocked).toBe(true);
284+
expect(host.chatUserNearBottom).toBe(false);
285+
286+
scheduleChatScroll(host);
287+
await host.updateComplete;
288+
289+
expect(container.scrollTop).toBe(1540);
290+
expect(host.chatNewMessagesBelow).toBe(true);
291+
292+
host.chatIsProgrammaticScroll = false;
293+
container.scrollTop = 1600;
294+
handleChatScroll(host, createScrollEvent(2000, 1600, 400));
295+
296+
expect(host.chatFollowLocked).toBe(false);
297+
expect(host.chatUserNearBottom).toBe(true);
298+
expect(host.chatNewMessagesBelow).toBe(false);
299+
});
300+
301+
it("does not re-stick streaming after a small user scroll-up near the bottom", async () => {
302+
const { host, container } = createScrollHost({
303+
scrollHeight: 2000,
304+
scrollTop: 1589,
305+
clientHeight: 400,
306+
});
307+
host.chatHasAutoScrolled = true;
308+
host.chatUserNearBottom = true;
309+
host.chatIsProgrammaticScroll = true;
310+
host.chatProgrammaticScrollTarget = 1800;
311+
host.chatLastScrollTop = 1600;
312+
313+
handleChatScroll(host, createScrollEvent(2000, 1589, 400));
314+
315+
expect(host.chatFollowLocked).toBe(true);
316+
expect(host.chatUserNearBottom).toBe(false);
317+
318+
scheduleChatScroll(host);
319+
await host.updateComplete;
320+
321+
expect(container.scrollTop).toBe(1589);
322+
expect(host.chatNewMessagesBelow).toBe(true);
323+
});
324+
251325
it("does NOT scroll automatically when chat auto-scroll is off", async () => {
252326
const { host, container } = createScrollHost({
253327
scrollHeight: 2000,
@@ -360,13 +434,15 @@ describe("resetChatScroll", () => {
360434
const { host } = createScrollHost({});
361435
host.chatHasAutoScrolled = true;
362436
host.chatUserNearBottom = false;
437+
host.chatFollowLocked = true;
363438
host.chatLastScrollTop = 300;
364439
host.chatHeaderControlsHidden = true;
365440

366441
resetChatScroll(host);
367442

368443
expect(host.chatHasAutoScrolled).toBe(false);
369444
expect(host.chatUserNearBottom).toBe(true);
445+
expect(host.chatFollowLocked).toBe(false);
370446
expect(host.chatLastScrollTop).toBe(0);
371447
expect(host.chatHeaderControlsHidden).toBe(false);
372448
expect(host.chatIsProgrammaticScroll).toBe(false);

ui/src/ui/app-scroll.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { normalizeChatAutoScrollMode, type ChatAutoScrollMode } from "./storage.
33

44
/** Distance (px) from the bottom within which we consider the user "near bottom". */
55
const NEAR_BOTTOM_THRESHOLD = 450;
6+
const FOLLOW_REACQUIRE_THRESHOLD = 8;
67
const HEADER_HIDE_SCROLL_DELTA = 12;
78
const HEADER_SHOW_TOP_THRESHOLD = 24;
89

@@ -15,6 +16,7 @@ type ScrollHost = {
1516
chatLastScrollTop: number;
1617
chatHasAutoScrolled: boolean;
1718
chatUserNearBottom: boolean;
19+
chatFollowLocked: boolean;
1820
chatHeaderControlsHidden: boolean;
1921
chatNewMessagesBelow: boolean;
2022
chatIsProgrammaticScroll: boolean;
@@ -85,8 +87,8 @@ export function scheduleChatScroll(
8587
autoScrollMode === "always" ||
8688
(autoScrollMode === "near-bottom" &&
8789
(effectiveForce ||
88-
host.chatUserNearBottom ||
89-
distanceFromBottom < NEAR_BOTTOM_THRESHOLD));
90+
(!host.chatFollowLocked &&
91+
(host.chatUserNearBottom || distanceFromBottom < NEAR_BOTTOM_THRESHOLD))));
9092

9193
if (!shouldStick) {
9294
// User is scrolled up — flag that new content arrived below.
@@ -96,6 +98,7 @@ export function scheduleChatScroll(
9698
if (effectiveForce) {
9799
host.chatHasAutoScrolled = true;
98100
}
101+
host.chatFollowLocked = false;
99102
const smoothEnabled =
100103
smooth &&
101104
(typeof window === "undefined" ||
@@ -129,8 +132,8 @@ export function scheduleChatScroll(
129132
autoScrollMode === "always" ||
130133
(autoScrollMode === "near-bottom" &&
131134
(effectiveForce ||
132-
host.chatUserNearBottom ||
133-
latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD));
135+
(!host.chatFollowLocked &&
136+
(host.chatUserNearBottom || latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD))));
134137
if (!shouldStickRetry) {
135138
return;
136139
}
@@ -207,21 +210,29 @@ export function handleChatScroll(host: ScrollHost, event: Event) {
207210
// Only suppress if scrollTop is still at or above the position we scrolled to;
208211
// if it dropped below, the user scrolled up during the guard window and we must
209212
// process the event so streaming stops pinning them back to the bottom.
213+
const isUserScrollUp = delta < 0;
214+
const isDeliberateScrollUp = delta < -HEADER_HIDE_SCROLL_DELTA;
210215
if (
211216
host.chatIsProgrammaticScroll &&
217+
!isUserScrollUp &&
212218
container.scrollTop >= host.chatProgrammaticScrollTarget - container.clientHeight
213219
) {
214220
return;
215221
}
216222
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
217-
host.chatUserNearBottom = distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
223+
if (isUserScrollUp && distanceFromBottom > FOLLOW_REACQUIRE_THRESHOLD) {
224+
host.chatFollowLocked = true;
225+
} else if (distanceFromBottom <= FOLLOW_REACQUIRE_THRESHOLD) {
226+
host.chatFollowLocked = false;
227+
}
228+
host.chatUserNearBottom = !host.chatFollowLocked && distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
218229
const hasUsefulScroll = container.scrollHeight - container.clientHeight > NEAR_BOTTOM_THRESHOLD;
219230

220231
if (!hasUsefulScroll || scrollTop <= HEADER_SHOW_TOP_THRESHOLD || host.chatUserNearBottom) {
221232
host.chatHeaderControlsHidden = false;
222233
} else if (delta > HEADER_HIDE_SCROLL_DELTA) {
223234
host.chatHeaderControlsHidden = true;
224-
} else if (delta < -HEADER_HIDE_SCROLL_DELTA) {
235+
} else if (isDeliberateScrollUp) {
225236
host.chatHeaderControlsHidden = false;
226237
}
227238

@@ -252,6 +263,7 @@ export function handleActivityScroll(host: ScrollHost, event: Event) {
252263
export function resetChatScroll(host: ScrollHost) {
253264
host.chatHasAutoScrolled = false;
254265
host.chatUserNearBottom = true;
266+
host.chatFollowLocked = false;
255267
host.chatLastScrollTop = 0;
256268
host.chatHeaderControlsHidden = false;
257269
host.chatNewMessagesBelow = false;

ui/src/ui/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,7 @@ export class OpenClawApp extends LitElement {
697697
chatLastScrollTop = 0;
698698
chatHasAutoScrolled = false;
699699
chatUserNearBottom = true;
700+
chatFollowLocked = false;
700701
chatIsProgrammaticScroll = false;
701702
chatProgrammaticScrollTarget = 0;
702703
@state() chatNewMessagesBelow = false;

0 commit comments

Comments
 (0)