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:
nickmopen
2026-05-10 23:07:19 -05:00
committed by GitHub
parent 7f07fbd487
commit b90f28e895
5 changed files with 212 additions and 3 deletions

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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);
});
});

View File

@@ -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) {

View File

@@ -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;