mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 13:24:48 +00:00
fix(ui): prevent programmatic scrollTo from flipping chatUserNearBott… (#76991)
* fix(ui): prevent programmatic scrollTo from flipping chatUserNearBottom during streaming * fix(ui): preserve user scroll-up events that arrive during programmatic scroll guard window * test(ui): add unit coverage for programmatic scroll guard boundary and retry path * fix(ui): preserve chat scroll bookkeeping * chore: drop unrelated slack formatting * test: narrow inbound dedupe claim result --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -1095,6 +1095,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory/LanceDB: declare `apache-arrow` in the bundled memory plugin package so LanceDB installs include its runtime peer. Fixes #76910. Thanks @afiqfiles-max.
|
||||
- CLI/devices: retry explicit device-pair approval with `operator.admin` after a pairing-scope ownership denial, so existing admin-capable paired-device tokens can recover new Control UI/browser pairing after upgrades instead of requiring manual JSON edits. Fixes #76956. Thanks @neo19482.
|
||||
- CLI/devices: stop local pairing fallback when the active Gateway names a pending request that is absent from the local pairing store, so profile or state-dir mismatches no longer make `openclaw devices list/approve` inspect the wrong store while a real device stays blocked. Thanks @vincentkoc.
|
||||
- Control UI/webchat: fix streaming assistant responses causing the chat viewport to scroll upward by guarding `handleChatScroll` against scroll events triggered by the auto-scroll logic itself; introduces a `chatIsProgrammaticScroll` flag that suppresses near-bottom state updates during programmatic `scrollTo` calls so streaming output stays pinned to the bottom. Thanks @nickmopen.
|
||||
- Google Meet: use the local call-control microphone button instead of disabled remote participant mute buttons, and block realtime speech when the OpenClaw Meet microphone remains muted.
|
||||
- Google Meet: refresh realtime browser state during status and retry delayed speech after Meet finishes joining, so a just-opened in-call tab no longer leaves speech stuck behind stale `not-in-call` health.
|
||||
- Plugins/install: recover the install ledger from the managed npm root when `plugins/installs.json` is empty or partial, so reinstalling Discord and Codex no longer makes the other installed plugin disappear.
|
||||
|
||||
@@ -141,9 +141,9 @@ function expectSaveMediaBufferCall(mock: unknown, contentType: string, maxBytes:
|
||||
}
|
||||
|
||||
function expectVerboseLogContains(expected: string): void {
|
||||
const messages = vi.mocked(logVerbose).mock.calls.map((call) =>
|
||||
typeof call[0] === "string" ? call[0] : "",
|
||||
);
|
||||
const messages = vi
|
||||
.mocked(logVerbose)
|
||||
.mock.calls.map((call) => (typeof call[0] === "string" ? call[0] : ""));
|
||||
expect(messages.some((message) => message.includes(expected))).toBe(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ function createScrollHost(
|
||||
chatUserNearBottom: true,
|
||||
chatHeaderControlsHidden: false,
|
||||
chatNewMessagesBelow: false,
|
||||
chatIsProgrammaticScroll: false,
|
||||
chatProgrammaticScrollTarget: 0,
|
||||
logsScrollFrame: null as number | null,
|
||||
logsAtBottom: true,
|
||||
topbarObserver: null as ResizeObserver | null,
|
||||
@@ -309,5 +311,183 @@ describe("resetChatScroll", () => {
|
||||
expect(host.chatUserNearBottom).toBe(true);
|
||||
expect(host.chatLastScrollTop).toBe(0);
|
||||
expect(host.chatHeaderControlsHidden).toBe(false);
|
||||
expect(host.chatIsProgrammaticScroll).toBe(false);
|
||||
expect(host.chatProgrammaticScrollTarget).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Programmatic scroll guard */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("programmatic scroll guard", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("handleChatScroll suppresses own scroll event when scrollTop is at the programmatic target", () => {
|
||||
const { host } = createScrollHost({});
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatIsProgrammaticScroll = true;
|
||||
// Simulates scrollTo(scrollHeight=1000): expected scrollTop = 1000 - 400 = 600.
|
||||
host.chatProgrammaticScrollTarget = 1000;
|
||||
|
||||
// Our own scroll event: scrollTop is at the clamped target position.
|
||||
const event = createScrollEvent(1000, 600, 400);
|
||||
handleChatScroll(host, event);
|
||||
|
||||
// Must remain true — our scroll-to-bottom event must not flip near-bottom state.
|
||||
expect(host.chatUserNearBottom).toBe(true);
|
||||
});
|
||||
|
||||
it("handleChatScroll processes user scroll-up that arrives during the guard window", () => {
|
||||
const { host } = createScrollHost({});
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatIsProgrammaticScroll = true;
|
||||
// We had targeted the bottom of a 3000px page.
|
||||
host.chatProgrammaticScrollTarget = 3000;
|
||||
|
||||
// User scrolled up to 500 during the guard window — far below the target (2600).
|
||||
const event = createScrollEvent(3000, 500, 400); // distanceFromBottom = 2100 > 450
|
||||
handleChatScroll(host, event);
|
||||
|
||||
// Must flip to false — user intentionally scrolled up, streaming must not re-pin them.
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
});
|
||||
|
||||
it("scheduleChatScroll sets chatIsProgrammaticScroll before scrolling and clears it after rAF", async () => {
|
||||
const { host } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 1600,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatHasAutoScrolled = true;
|
||||
|
||||
scheduleChatScroll(host);
|
||||
await host.updateComplete;
|
||||
|
||||
// After rAF cleanup the flag must be cleared.
|
||||
expect(host.chatIsProgrammaticScroll).toBe(false);
|
||||
// Target was set to container scrollHeight before scrollTo.
|
||||
expect(host.chatProgrammaticScrollTarget).toBe(2000);
|
||||
// And scroll must have happened.
|
||||
expect(host.chatUserNearBottom).toBe(true);
|
||||
});
|
||||
|
||||
it("after programmatic scroll is done, a real user scroll-up correctly flips chatUserNearBottom to false", async () => {
|
||||
const { host } = createScrollHost({
|
||||
scrollHeight: 3000,
|
||||
scrollTop: 500,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatUserNearBottom = true;
|
||||
// Flag already cleared — simulates the state after the rAF cleanup ran.
|
||||
host.chatIsProgrammaticScroll = false;
|
||||
|
||||
// User genuinely scrolled far from bottom — must be respected.
|
||||
const event = createScrollEvent(3000, 500, 400); // distanceFromBottom = 2100 > 450
|
||||
handleChatScroll(host, event);
|
||||
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
});
|
||||
|
||||
it("guard boundary: scrollTop exactly one pixel below threshold is NOT suppressed (user scroll-up passes through)", () => {
|
||||
const { host } = createScrollHost({});
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatIsProgrammaticScroll = true;
|
||||
// Programmatic target = 1000, clientHeight = 400 → threshold = 600.
|
||||
// scrollTop = 599 → 599 >= 600 is false → guard does NOT suppress the event.
|
||||
host.chatProgrammaticScrollTarget = 1000;
|
||||
|
||||
const event = createScrollEvent(1000, 599, 400); // distanceFromBottom = 1
|
||||
handleChatScroll(host, event);
|
||||
|
||||
// Event was processed: user is near bottom (dist=1 < 450) but the guard did not block it.
|
||||
expect(host.chatUserNearBottom).toBe(true);
|
||||
// chatLastScrollTop must have been updated — confirms the event was not short-circuited.
|
||||
expect(host.chatLastScrollTop).toBe(599);
|
||||
});
|
||||
|
||||
it("guard boundary: scrollTop exactly at threshold is suppressed", () => {
|
||||
const { host } = createScrollHost({});
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatIsProgrammaticScroll = true;
|
||||
host.chatProgrammaticScrollTarget = 1000;
|
||||
host.chatLastScrollTop = 0;
|
||||
|
||||
// scrollTop = 600 → 600 >= 600 is true → guard suppresses the event.
|
||||
const event = createScrollEvent(1000, 600, 400);
|
||||
handleChatScroll(host, event);
|
||||
|
||||
// Scroll bookkeeping still advances so the next user scroll has the right direction.
|
||||
expect(host.chatLastScrollTop).toBe(600);
|
||||
});
|
||||
|
||||
it("suppressed programmatic scroll event does not mutate chatNewMessagesBelow", () => {
|
||||
const { host } = createScrollHost({});
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatNewMessagesBelow = false;
|
||||
host.chatIsProgrammaticScroll = true;
|
||||
host.chatProgrammaticScrollTarget = 2000;
|
||||
|
||||
// Our own scroll event at the programmatic target position.
|
||||
const event = createScrollEvent(2000, 1600, 400);
|
||||
handleChatScroll(host, event);
|
||||
|
||||
// Event was suppressed — chatNewMessagesBelow must stay unchanged.
|
||||
expect(host.chatNewMessagesBelow).toBe(false);
|
||||
});
|
||||
|
||||
it("suppressed programmatic scroll preserves direction bookkeeping for the next user scroll-up", () => {
|
||||
const { host } = createScrollHost({});
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatHeaderControlsHidden = true;
|
||||
host.chatIsProgrammaticScroll = true;
|
||||
host.chatProgrammaticScrollTarget = 3000;
|
||||
host.chatLastScrollTop = 0;
|
||||
|
||||
handleChatScroll(host, createScrollEvent(3000, 2600, 400));
|
||||
expect(host.chatLastScrollTop).toBe(2600);
|
||||
|
||||
host.chatIsProgrammaticScroll = false;
|
||||
handleChatScroll(host, createScrollEvent(3000, 2000, 400));
|
||||
|
||||
expect(host.chatHeaderControlsHidden).toBe(false);
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
});
|
||||
|
||||
it("retry timeout sets and clears chatIsProgrammaticScroll", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 1600,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatHasAutoScrolled = true;
|
||||
|
||||
scheduleChatScroll(host);
|
||||
await host.updateComplete;
|
||||
|
||||
// After the initial rAF the flag must already be cleared.
|
||||
expect(host.chatIsProgrammaticScroll).toBe(false);
|
||||
|
||||
// Advance past the retry delay (120ms) — retry scrollTop assignment fires.
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
// After the retry's synchronous scrollTop assignment, the flag is set true.
|
||||
// A subsequent rAF clears it — but our mock runs rAF synchronously.
|
||||
expect(host.chatIsProgrammaticScroll).toBe(false);
|
||||
// Retry must have updated the programmatic target and scrolled.
|
||||
expect(host.chatProgrammaticScrollTarget).toBe(container.scrollHeight);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ type ScrollHost = {
|
||||
chatUserNearBottom: boolean;
|
||||
chatHeaderControlsHidden: boolean;
|
||||
chatNewMessagesBelow: boolean;
|
||||
chatIsProgrammaticScroll: boolean;
|
||||
chatProgrammaticScrollTarget: number;
|
||||
logsScrollFrame: number | null;
|
||||
logsAtBottom: boolean;
|
||||
topbarObserver: ResizeObserver | null;
|
||||
@@ -75,11 +77,17 @@ export function scheduleChatScroll(host: ScrollHost, force = false, smooth = fal
|
||||
typeof window.matchMedia !== "function" ||
|
||||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||
const scrollTop = target.scrollHeight;
|
||||
host.chatProgrammaticScrollTarget = scrollTop;
|
||||
host.chatIsProgrammaticScroll = true;
|
||||
if (typeof target.scrollTo === "function") {
|
||||
target.scrollTo({ top: scrollTop, behavior: smoothEnabled ? "smooth" : "auto" });
|
||||
} else {
|
||||
target.scrollTop = scrollTop;
|
||||
}
|
||||
// Clear the flag after the scroll event has fired (sync or next microtask).
|
||||
requestAnimationFrame(() => {
|
||||
host.chatIsProgrammaticScroll = false;
|
||||
});
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatNewMessagesBelow = false;
|
||||
const retryDelay = effectiveForce ? 150 : 120;
|
||||
@@ -98,7 +106,12 @@ export function scheduleChatScroll(host: ScrollHost, force = false, smooth = fal
|
||||
if (!shouldStickRetry) {
|
||||
return;
|
||||
}
|
||||
host.chatProgrammaticScrollTarget = latest.scrollHeight;
|
||||
host.chatIsProgrammaticScroll = true;
|
||||
latest.scrollTop = latest.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
host.chatIsProgrammaticScroll = false;
|
||||
});
|
||||
host.chatUserNearBottom = true;
|
||||
}, retryDelay);
|
||||
});
|
||||
@@ -135,6 +148,17 @@ export function handleChatScroll(host: ScrollHost, event: Event) {
|
||||
const scrollTop = Math.max(0, container.scrollTop);
|
||||
const delta = scrollTop - host.chatLastScrollTop;
|
||||
host.chatLastScrollTop = scrollTop;
|
||||
// Ignore scroll events that we ourselves triggered — they must not flip
|
||||
// chatUserNearBottom to false while streaming content grows the page.
|
||||
// Only suppress if scrollTop is still at or above the position we scrolled to;
|
||||
// if it dropped below, the user scrolled up during the guard window and we must
|
||||
// process the event so streaming stops pinning them back to the bottom.
|
||||
if (
|
||||
host.chatIsProgrammaticScroll &&
|
||||
container.scrollTop >= host.chatProgrammaticScrollTarget - container.clientHeight
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
host.chatUserNearBottom = distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
|
||||
const hasUsefulScroll = container.scrollHeight - container.clientHeight > NEAR_BOTTOM_THRESHOLD;
|
||||
@@ -168,6 +192,8 @@ export function resetChatScroll(host: ScrollHost) {
|
||||
host.chatLastScrollTop = 0;
|
||||
host.chatHeaderControlsHidden = false;
|
||||
host.chatNewMessagesBelow = false;
|
||||
host.chatIsProgrammaticScroll = false;
|
||||
host.chatProgrammaticScrollTarget = 0;
|
||||
}
|
||||
|
||||
export function exportLogs(lines: string[], label: string) {
|
||||
|
||||
@@ -592,6 +592,8 @@ export class OpenClawApp extends LitElement {
|
||||
chatLastScrollTop = 0;
|
||||
chatHasAutoScrolled = false;
|
||||
chatUserNearBottom = true;
|
||||
chatIsProgrammaticScroll = false;
|
||||
chatProgrammaticScrollTarget = 0;
|
||||
@state() chatNewMessagesBelow = false;
|
||||
nodesPollInterval: number | null = null;
|
||||
logsPollInterval: number | null = null;
|
||||
|
||||
Reference in New Issue
Block a user