fix(webchat): respect user scroll position during streaming and refresh

- Increase near-bottom threshold from 200px to 450px so one long message
  doesn't falsely register as 'near bottom'
- Make force=true only override on initial load (chatHasAutoScrolled=false),
  not on subsequent refreshChat() calls
- refreshChat() no longer passes force=true to scheduleChatScroll
- Add chatNewMessagesBelow flag for future 'scroll to bottom' button UI
- Clear chatNewMessagesBelow when user scrolls back to bottom
- Add 13 unit tests covering threshold, force behavior, streaming, and reset
This commit is contained in:
Marco Marandiz
2026-02-02 09:19:45 -06:00
committed by Shakker
parent 991ed3ab58
commit e18f43ddad
4 changed files with 300 additions and 5 deletions

View File

@@ -210,7 +210,7 @@ export async function refreshChat(host: ChatHost) {
}),
refreshChatAvatar(host),
]);
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
}
export const flushChatQueueForEvent = flushChatQueue;

View File

@@ -0,0 +1,273 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { handleChatScroll, scheduleChatScroll, resetChatScroll } from "./app-scroll";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/** Minimal ScrollHost stub for unit tests. */
function createScrollHost(overrides: {
scrollHeight?: number;
scrollTop?: number;
clientHeight?: number;
overflowY?: string;
}) {
const {
scrollHeight = 2000,
scrollTop = 1500,
clientHeight = 500,
overflowY = "auto",
} = overrides;
const container = {
scrollHeight,
scrollTop,
clientHeight,
style: { overflowY } as unknown as CSSStyleDeclaration,
};
// Make getComputedStyle return the overflowY value
vi.spyOn(window, "getComputedStyle").mockReturnValue({
overflowY,
} as unknown as CSSStyleDeclaration);
const host = {
updateComplete: Promise.resolve(),
querySelector: vi.fn().mockReturnValue(container),
style: { setProperty: vi.fn() } as unknown as CSSStyleDeclaration,
chatScrollFrame: null as number | null,
chatScrollTimeout: null as number | null,
chatHasAutoScrolled: false,
chatUserNearBottom: true,
chatNewMessagesBelow: false,
logsScrollFrame: null as number | null,
logsAtBottom: true,
topbarObserver: null as ResizeObserver | null,
};
return { host, container };
}
function createScrollEvent(scrollHeight: number, scrollTop: number, clientHeight: number) {
return {
currentTarget: { scrollHeight, scrollTop, clientHeight },
} as unknown as Event;
}
/* ------------------------------------------------------------------ */
/* handleChatScroll threshold tests */
/* ------------------------------------------------------------------ */
describe("handleChatScroll", () => {
it("sets chatUserNearBottom=true when within the 450px threshold", () => {
const { host } = createScrollHost({});
// distanceFromBottom = 2000 - 1600 - 400 = 0 → clearly near bottom
const event = createScrollEvent(2000, 1600, 400);
handleChatScroll(host, event);
expect(host.chatUserNearBottom).toBe(true);
});
it("sets chatUserNearBottom=true when distance is just under threshold", () => {
const { host } = createScrollHost({});
// distanceFromBottom = 2000 - 1151 - 400 = 449 → just under threshold
const event = createScrollEvent(2000, 1151, 400);
handleChatScroll(host, event);
expect(host.chatUserNearBottom).toBe(true);
});
it("sets chatUserNearBottom=false when distance is exactly at threshold", () => {
const { host } = createScrollHost({});
// distanceFromBottom = 2000 - 1150 - 400 = 450 → at threshold (uses strict <)
const event = createScrollEvent(2000, 1150, 400);
handleChatScroll(host, event);
expect(host.chatUserNearBottom).toBe(false);
});
it("sets chatUserNearBottom=false when scrolled well above threshold", () => {
const { host } = createScrollHost({});
// distanceFromBottom = 2000 - 500 - 400 = 1100 → way above threshold
const event = createScrollEvent(2000, 500, 400);
handleChatScroll(host, event);
expect(host.chatUserNearBottom).toBe(false);
});
it("sets chatUserNearBottom=false when user scrolled up past one long message (>200px <450px)", () => {
const { host } = createScrollHost({});
// distanceFromBottom = 2000 - 1250 - 400 = 350 → old threshold would say "near", new says "near"
// distanceFromBottom = 2000 - 1100 - 400 = 500 → old threshold would say "not near", new also "not near"
const event = createScrollEvent(2000, 1100, 400);
handleChatScroll(host, event);
expect(host.chatUserNearBottom).toBe(false);
});
});
/* ------------------------------------------------------------------ */
/* scheduleChatScroll respects user scroll position */
/* ------------------------------------------------------------------ */
describe("scheduleChatScroll", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
cb(0);
return 1;
});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("scrolls to bottom when user is near bottom (no force)", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 1600,
clientHeight: 400,
});
// distanceFromBottom = 2000 - 1600 - 400 = 0 → near bottom
host.chatUserNearBottom = true;
scheduleChatScroll(host);
await host.updateComplete;
expect(container.scrollTop).toBe(container.scrollHeight);
});
it("does NOT scroll when user is scrolled up and no force", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 500,
clientHeight: 400,
});
// distanceFromBottom = 2000 - 500 - 400 = 1100 → not near bottom
host.chatUserNearBottom = false;
const originalScrollTop = container.scrollTop;
scheduleChatScroll(host);
await host.updateComplete;
expect(container.scrollTop).toBe(originalScrollTop);
});
it("does NOT scroll with force=true when user has explicitly scrolled up", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 500,
clientHeight: 400,
});
// User has scrolled up — chatUserNearBottom is false
host.chatUserNearBottom = false;
host.chatHasAutoScrolled = true; // Already past initial load
const originalScrollTop = container.scrollTop;
scheduleChatScroll(host, true);
await host.updateComplete;
// force=true should still NOT override explicit user scroll-up after initial load
expect(container.scrollTop).toBe(originalScrollTop);
});
it("DOES scroll with force=true on initial load (chatHasAutoScrolled=false)", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 500,
clientHeight: 400,
});
host.chatUserNearBottom = false;
host.chatHasAutoScrolled = false; // Initial load
scheduleChatScroll(host, true);
await host.updateComplete;
// On initial load, force should work regardless
expect(container.scrollTop).toBe(container.scrollHeight);
});
it("sets chatNewMessagesBelow when not scrolling due to user position", async () => {
const { host } = createScrollHost({
scrollHeight: 2000,
scrollTop: 500,
clientHeight: 400,
});
host.chatUserNearBottom = false;
host.chatHasAutoScrolled = true;
host.chatNewMessagesBelow = false;
scheduleChatScroll(host);
await host.updateComplete;
expect(host.chatNewMessagesBelow).toBe(true);
});
});
/* ------------------------------------------------------------------ */
/* Streaming: rapid chatStream changes should not reset scroll */
/* ------------------------------------------------------------------ */
describe("streaming scroll behavior", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
cb(0);
return 1;
});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("multiple rapid scheduleChatScroll calls do not scroll when user is scrolled up", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 500,
clientHeight: 400,
});
host.chatUserNearBottom = false;
host.chatHasAutoScrolled = true;
const originalScrollTop = container.scrollTop;
// Simulate rapid streaming token updates
scheduleChatScroll(host);
scheduleChatScroll(host);
scheduleChatScroll(host);
await host.updateComplete;
expect(container.scrollTop).toBe(originalScrollTop);
});
it("streaming scrolls correctly when user IS at bottom", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 1600,
clientHeight: 400,
});
host.chatUserNearBottom = true;
host.chatHasAutoScrolled = true;
// Simulate streaming
scheduleChatScroll(host);
await host.updateComplete;
expect(container.scrollTop).toBe(container.scrollHeight);
});
});
/* ------------------------------------------------------------------ */
/* resetChatScroll */
/* ------------------------------------------------------------------ */
describe("resetChatScroll", () => {
it("resets state for new chat session", () => {
const { host } = createScrollHost({});
host.chatHasAutoScrolled = true;
host.chatUserNearBottom = false;
resetChatScroll(host);
expect(host.chatHasAutoScrolled).toBe(false);
expect(host.chatUserNearBottom).toBe(true);
});
});

View File

@@ -1,3 +1,6 @@
/** Distance (px) from the bottom within which we consider the user "near bottom". */
const NEAR_BOTTOM_THRESHOLD = 450;
type ScrollHost = {
updateComplete: Promise<unknown>;
querySelector: (selectors: string) => Element | null;
@@ -6,6 +9,7 @@ type ScrollHost = {
chatScrollTimeout: number | null;
chatHasAutoScrolled: boolean;
chatUserNearBottom: boolean;
chatNewMessagesBelow: boolean;
logsScrollFrame: number | null;
logsAtBottom: boolean;
topbarObserver: ResizeObserver | null;
@@ -42,15 +46,24 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
return;
}
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
const shouldStick = force || host.chatUserNearBottom || distanceFromBottom < 200;
// force=true only overrides when we haven't auto-scrolled yet (initial load).
// After initial load, respect the user's scroll position.
const effectiveForce = force && !host.chatHasAutoScrolled;
const shouldStick =
effectiveForce || host.chatUserNearBottom || distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
if (!shouldStick) {
// User is scrolled up — flag that new content arrived below.
host.chatNewMessagesBelow = true;
return;
}
if (force) {
if (effectiveForce) {
host.chatHasAutoScrolled = true;
}
target.scrollTop = target.scrollHeight;
host.chatUserNearBottom = true;
host.chatNewMessagesBelow = false;
const retryDelay = force ? 150 : 120;
host.chatScrollTimeout = window.setTimeout(() => {
host.chatScrollTimeout = null;
@@ -60,7 +73,10 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
}
const latestDistanceFromBottom =
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
const shouldStickRetry = force || host.chatUserNearBottom || latestDistanceFromBottom < 200;
const shouldStickRetry =
effectiveForce ||
host.chatUserNearBottom ||
latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD;
if (!shouldStickRetry) {
return;
}
@@ -99,7 +115,11 @@ export function handleChatScroll(host: ScrollHost, event: Event) {
return;
}
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
host.chatUserNearBottom = distanceFromBottom < 200;
host.chatUserNearBottom = distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
// Clear the "new messages below" indicator when user scrolls back to bottom.
if (host.chatUserNearBottom) {
host.chatNewMessagesBelow = false;
}
}
export function handleLogsScroll(host: ScrollHost, event: Event) {
@@ -114,6 +134,7 @@ export function handleLogsScroll(host: ScrollHost, event: Event) {
export function resetChatScroll(host: ScrollHost) {
host.chatHasAutoScrolled = false;
host.chatUserNearBottom = true;
host.chatNewMessagesBelow = false;
}
export function exportLogs(lines: string[], label: string) {

View File

@@ -252,6 +252,7 @@ export class OpenClawApp extends LitElement {
private chatScrollTimeout: number | null = null;
private chatHasAutoScrolled = false;
private chatUserNearBottom = true;
chatNewMessagesBelow = false;
private nodesPollInterval: number | null = null;
private logsPollInterval: number | null = null;
private debugPollInterval: number | null = null;